From d3c76be9c16f3dc2090bafe9a27959b59ccb20b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 01:20:53 +0100 Subject: [PATCH 001/281] Unify Explorer tabs and refine launch controls (#5942) * feat(app): unify Explorer tabs and refine interface styling Give Explorer the workspace tab controls while keeping launcher choices separate from drag-and-drop support. Upgrade Lucide and set a shared icon stroke width for consistent styling. Reduce the desktop composer bottom inset to align the action row with its corners. * fix(app): show active Explorer tabs in foreground The shared row coupled foreground styling to keyboard shortcut ownership. Separate those concerns so Explorer can highlight its active tab while Cmd+T remains with the workspace pane. * fix(app): refine Explorer close controls and launch options * test(app): respect singleton Files in Explorer plugin workflow * test(server): trim unused setup from no-track checkout regression * test(app): count visible main tab rows after pane moves --- docs/explorer-sidebar.md | 18 +- package-lock.json | 30 +- .../changes-shortcut-placement.spec.ts | 2 +- .../e2e/browser/empty-pane-keyboard.spec.ts | 2 +- .../browser/explorer-pane-placement.spec.ts | 17 +- .../e2e/browser/explorer-plugin-menu.spec.ts | 10 +- .../app/e2e/browser/explorer-sidebar.spec.ts | 213 +++++++- .../browser/explorer-surface-upgrade.spec.ts | 10 +- packages/app/e2e/browser/file-editing.spec.ts | 7 +- packages/app/e2e/browser/launcher-tab.spec.ts | 2 +- .../app/e2e/browser/sidebar-workspace.spec.ts | 19 +- .../support/helpers/explorer-plugin-menu.ts | 97 ++-- .../app/e2e/support/helpers/workspace-tabs.ts | 6 +- packages/app/package.json | 2 +- packages/app/src/app/_layout.tsx | 15 +- .../app/src/components/add-project-flow.tsx | 6 +- .../app/src/components/split-container.tsx | 27 +- packages/app/src/composer/input/input.tsx | 7 +- packages/app/src/panels/new-tab-panel.tsx | 1 + .../app/src/panels/panel-manifest.test.ts | 24 +- packages/app/src/panels/panel-manifest.ts | 37 ++ .../workspace/explorer-sidebar-tab-rail.tsx | 468 ------------------ .../screens/workspace/explorer-sidebar.tsx | 115 ++++- .../workspace/workspace-desktop-tabs-row.tsx | 51 +- .../workspace/workspace-new-tab-menu.tsx | 11 +- .../workspace/workspace-tab-layout.test.ts | 27 +- .../workspace/workspace-tab-presentation.tsx | 3 + packages/app/src/styles/theme.ts | 2 + packages/app/src/subagents/track.tsx | 2 + .../app/src/workspace-tabs/launcher/index.tsx | 33 +- .../launcher/internal/catalog.test.ts | 2 +- .../launcher/internal/catalog.ts | 2 +- .../server/src/utils/checkout-git.test.ts | 7 +- .../src/components/mockup/titlebar.tsx | 2 +- 34 files changed, 605 insertions(+), 672 deletions(-) delete mode 100644 packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 2ecf842671..f1e27ac548 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never panel type for another. Tab moves reject unsupported destinations, and placement resolves only to a compatible pane. -Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible -tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes. +Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons, +so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu +item available again. Other compatible tabs, including agents, terminals, files, and diffs, +can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. @@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T that pane from the workspace split tree and docks it separately. Persisted identifiers retain the literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility. -The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and -toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared -launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs -can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace -focus. +Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes +hide their close buttons through the panel manifest; close them from the tab context menu. +Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and +omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer. +Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged +between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab +does not change workspace focus. Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders. diff --git a/package-lock.json b/package-lock.json index 8a71d40f8c..6e8fb5385f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26270,17 +26270,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lucide-react-native": { - "version": "0.546.0", - "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz", - "integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-native": "*", - "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -36489,7 +36478,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", @@ -37344,6 +37333,23 @@ "node": ">=20.19.0" } }, + "packages/app/node_modules/lucide-react-native": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz", + "integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "packages/app/node_modules/miniflare": { "version": "4.20260625.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", diff --git a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts index a27880a111..74e6971c8d 100644 --- a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts +++ b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts @@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) = await page.keyboard.press(CHANGES_SHORTCUT); const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({ + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({ timeout: 30_000, }); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts index 96aac4d0d7..46dc4cf47f 100644 --- a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => { await waitForWorkspaceTabsVisible(page); const explorer = await openExplorerWithKeyboard(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index de0eafe677..55f96cc0cf 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator { return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); } -/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */ +/** Scope the shared tab row to the Explorer dock. */ function explorerTabRow(page: Page): Locator { - return visible(page, "explorer-sidebar-tab-rail"); + return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); } function mainTabRow(page: Page): Locator { - return visible(page, "workspace-tabs-row"); + return page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }); } async function selectExplorerChanges(page: Page): Promise { @@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => { contentType: "image/png", }); // The split must have taken: agent pane + New main. Explorer keeps its own rail. - await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 }); + await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 }); await expect(explorerTabRow(page)).toHaveCount(1); await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible(); }); @@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise { } async function moveOnlyDraftIntoRightSplit(page: Page): Promise { - await page.getByRole("button", { name: "More actions", exact: true }).click(); - await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click(); + await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Split pane right/ }).click(); const target = await emptyPaneBox(page); await dragChipTo(page, draftTabChip(page), { x: target.x + target.width / 2, y: target.y + target.height / 2, }); - await expect(visible(page, "workspace-tabs-row")).toHaveCount(1); + await expect(mainTabRow(page)).toHaveCount(1); } async function expectNewLauncher(page: Page): Promise { diff --git a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts index 8dc515ef74..a871718b88 100644 --- a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts +++ b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts @@ -3,20 +3,20 @@ import { withExplorerPanelPlugins, expectWorkspacePanelsInExplorerLauncher, openWorkspacePanelFromExplorerMenu, - expectIndependentExplorerPanelToggles, + expectIndependentExplorerPanelTabs, expectExplorerPanelWithFocusedAgent, } from "../support/helpers/explorer-plugin-menu"; -test("Explorer toggles workspace panels independently without inheriting agent context", async ({ +test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({ page, }, testInfo) => { await withExplorerPanelPlugins(page, async (workspace) => { - await test.step("the New Tab launcher offers compatible workspace panels", () => + await test.step("the + menu offers compatible workspace panels", () => expectWorkspacePanelsInExplorerLauncher(page)); - await test.step("the rail menu offers the same workspace panels", () => + await test.step("the + menu opens a workspace panel in Explorer", () => openWorkspacePanelFromExplorerMenu(page, workspace, testInfo)); await test.step("different panels and plugins keep independent selection and closing", () => - expectIndependentExplorerPanelToggles(page, workspace)); + expectIndependentExplorerPanelTabs(page, workspace)); await test.step("focusing an agent does not add agent panels to Explorer", () => expectExplorerPanelWithFocusedAgent(page, workspace, testInfo)); }); diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index 0d8db71435..c8536c884a 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "../support/fixtures"; -import { gotoWorkspace } from "../support/helpers/launcher"; +import { + gotoWorkspace, + pressNewTabShortcut, + expectTabTitleFits, +} from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { ensureExplorerSidebar, @@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters[0]) { return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); } +async function expectExplorerActiveTabForeground( + page: Parameters[0], +) { + await openFilesPanel(page); + const main = page.getByTestId("workspace-pane-main"); + await main.hover(); + const activeMainLabel = main + .getByTestId("workspace-tabs-row") + .locator('[aria-selected="true"]') + .getByText("New tab", { exact: true }); + const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color); + const activeFiles = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await expect(activeFiles).toHaveAttribute("aria-selected", "true"); + await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground); +} + test.describe("Explorer sidebar", () => { test("starts with Files and Changes, switches views, and toggles without changing main", async ({ page, @@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => { .count(); const explorer = await ensureExplorerSidebar(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); - await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1); - await openFilesPanel(page); + await expectExplorerActiveTabForeground(page); await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); - await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click(); + await explorer.getByTestId("workspace-tab-changes_tree").click(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); await page.getByTestId("workspace-explorer-toggle").first().click(); @@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => { } }); }); + +async function launchExplorerPanel( + page: Parameters[0], + name: string, +) { + const explorer = explorerSidebar(page); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click(); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function closeExplorerFilesFromContextMenu( + page: Parameters[0], +) { + const explorer = explorerSidebar(page); + const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true }); + await files.hover(); + await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0); + await explorer.getByTestId("workspace-tab-changes_tree").hover(); + await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); +} + +async function expectWorkspaceCloseOnHover(page: Parameters[0]) { + const main = page.getByTestId("workspace-pane-main"); + const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]'); + const close = main.getByRole("button", { name: "Close", exact: true }); + await explorerSidebar(page).hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "0"); + await tab.hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "1"); +} + +async function closeOtherExplorerTabs(page: Parameters[0]) { + const files = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + const confirmation = page.waitForEvent("dialog").then((dialog) => { + expect(dialog.message()).toContain("close 1 tab"); + return dialog.accept(); + }); + await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click(); + await confirmation; +} + +test("Explorer keeps Files and Changes close actions in the context menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + const main = page.getByTestId("workspace-pane-main"); + const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count(); + const explorer = await ensureExplorerSidebar(page); + await expectTabTitleFits(page, "Files", { min: 64, max: 90 }); + + await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => { + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible(); + const entries = await menu.getByRole("menuitem").allTextContents(); + expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan( + entries.findIndex((entry) => entry.startsWith("Diff")), + ); + await page.keyboard.press("Escape"); + await main.getByTestId("workspace-new-tab-button").click(); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible(); + await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + }); + + await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => { + await expectWorkspaceCloseOnHover(page); + await closeExplorerFilesFromContextMenu(page); + await launchExplorerPanel(page, "Files"); + await expect( + explorer.getByRole("button", { name: "Browse workspace files", exact: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + }); + + await test.step("standard bulk-close menus affect only Explorer tabs", async () => { + await closeOtherExplorerTabs(page); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount( + mainTabsBefore, + ); + await launchExplorerPanel(page, "Changes"); + await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); + }); + + await test.step("Cmd+T still belongs to the focused workspace pane", async () => { + await pressNewTabShortcut(page); + await expect( + main.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0); + }); + + await testInfo.attach("shared-explorer-tabs", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); + +async function dragAgentIntoExplorer( + page: Parameters[0], + agentId: string, +) { + const source = page + .getByTestId("workspace-pane-main") + .getByTestId(`workspace-tab-agent_${agentId}`); + const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); + const sourceBox = await source.boundingBox(); + const destinationBox = await destination.boundingBox(); + if (!sourceBox || !destinationBox) + throw new Error("Tab drag requires visible source and destination"); + const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 12, start.y + 4); + await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { + steps: 20, + }); + await page.mouse.up(); +} + +test("agents can still be dragged into Explorer when absent from its + menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + const explorer = await ensureExplorerSidebar(page); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Dragged agent", + modeId: "load-test", + model: "ten-second-stream", + }); + const main = page.getByTestId("workspace-pane-main"); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await dragAgentIntoExplorer(page, agent.id); + await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await testInfo.attach("agent-in-explorer", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index 9e2e6a2378..95a5a62bc3 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => const toggle = visible(page, "workspace-explorer-toggle").first(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect( - explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"), - ).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 }); @@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await test.step("open the explorer so both sides are showing", async () => { await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); await expect(agentList).toHaveCount(1); }); @@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await page.keyboard.press(`${modifier}+.`); await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index d426a03fb4..3c7475627d 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -279,7 +279,12 @@ test.describe("CodeMirror workspace file editing", () => { await openAgentRoute(page, session); await openWorkspaceFile(page, "target.ts"); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(2); await page .getByTestId(`workspace-tab-agent_${session.agentId}`) diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 9be6d79418..e9d43ced27 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => { expect(counts.every((count) => count === initialCount)).toBe(true); expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); - await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); + await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 }); }); test("Terminal transition completes within visual budget", async ({ page }) => { diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index b588add807..700d1e200f 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -572,18 +572,21 @@ test.describe("Half-screen desktop layout", () => { await openFilesPanel(page); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toBeVisible(); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); - await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible(); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(1); await explorerToggle.click(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toHaveCount(0); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( + 0, + ); await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); } finally { diff --git a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts index 2d13080274..ccccac8570 100644 --- a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts +++ b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts @@ -35,18 +35,14 @@ export default function contribute(client) { async function openExplorerMenu(page: Page) { const explorer = await ensureExplorerSidebar(page); - // The rail's empty background has no accessible role; tab menus are separate. - await explorer.getByTestId("explorer-sidebar-tab-rail").click({ - button: "right", - position: { x: 20, y: 2 }, - }); - const menu = page.getByTestId("explorer-sidebar-tab-configuration"); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); await expect(menu).toHaveCSS("opacity", "1"); return menu; } -async function toggleExplorerView(page: Page, name: string) { +async function launchExplorerView(page: Page, name: string | RegExp) { const menu = await openExplorerMenu(page); await menu.getByRole("menuitem", { name, exact: true }).click(); await expect(menu).not.toBeVisible(); @@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins( export async function expectWorkspacePanelsInExplorerLauncher(page: Page) { const menu = await openExplorerMenu(page); - await menu.getByRole("menuitem", { name: "New tab", exact: true }).click(); - const launcher = page.getByTestId("workspace-explorer-sidebar"); - await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible(); - await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0); - await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount( + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount( 0, ); + await page.keyboard.press("Escape"); } export async function openWorkspacePanelFromExplorerMenu( @@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu( body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }), contentType: "image/png", }); - await expect(menu.getByRole("menuitem")).toHaveText([ - "New tab", - "Changes", - "Files", - "Other review", - "Other review summary", - "Review", - "Review summary", - ]); - await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "false", - ); + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Other review summary", exact: true }), + ).toBeVisible(); await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); await expect( page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); } -export async function expectIndependentExplorerPanelToggles( +export async function expectIndependentExplorerPanelTabs( page: Page, workspace: ExplorerPanelWorkspace, ) { - await toggleExplorerView(page, "Other review"); + await launchExplorerView(page, "Other review"); await expect( page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); - await toggleExplorerView(page, "Review summary"); + await launchExplorerView(page, "Review summary"); + const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + await tabs + .getByRole("button", { name: "Review", exact: true }) + .click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true }); + await expect(files).toHaveCount(1); const menu = await openExplorerMenu(page); - await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); - const updated = await openExplorerMenu(page); - await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "false", - ); - await expect( - updated.getByRole("menuitem", { name: "Other review", exact: true }), - ).toHaveAttribute("aria-checked", "true"); - await expect( - updated.getByRole("menuitem", { name: "Review summary", exact: true }), - ).toHaveAttribute("aria-checked", "true"); + await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await page.keyboard.press("Escape"); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); + await launchExplorerView(page, /^Files/); + await expect(files).toHaveCount(1); + const restoredMenu = await openExplorerMenu(page); + await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); await page.keyboard.press("Escape"); - await toggleExplorerView(page, "Files"); - await toggleExplorerView(page, "Files"); - await expect( - page - .getByTestId("explorer-sidebar-tab-rail") - .getByRole("button", { name: "Browse workspace files", exact: true }), - ).toHaveCount(1); } export async function expectExplorerPanelWithFocusedAgent( diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 9477556823..8ddd507563 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -89,7 +89,7 @@ async function openExplorerView( ): Promise { const explorer = await ensureExplorerSidebar(page); const tab = explorer.getByTestId(view.tabTestId); - await tab.click(); + await selectWorkspaceTab(tab); await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ timeout: view.timeout ?? 30_000, }); @@ -97,7 +97,7 @@ async function openExplorerView( export async function openChangesTreePanel(page: Page): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-changes_tree", + tabTestId: "workspace-tab-changes_tree", contentTestId: "changes-tree-panel", }); } @@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-files", + tabTestId: "workspace-tab-files", contentTestId: "file-explorer-tree-scroll", }); } diff --git a/packages/app/package.json b/packages/app/package.json index 2d6f308206..a44656bc18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -102,7 +102,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 0c926c559c..2eb4e5185a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,6 +1,7 @@ import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; +import { LucideProvider } from "lucide-react-native"; import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; @@ -113,7 +114,7 @@ import { import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; -import { getNextThemePreference } from "@/styles/theme"; +import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; @@ -972,11 +973,13 @@ function RootAppTree() { return ( - - - - - + + + + + + + ); diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 4dcd50b019..d874a73618 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -6,7 +6,6 @@ import { Folder, FolderOpen, FolderPlus, - Github, HardDrive, Plus, Search, @@ -34,6 +33,7 @@ import { type EditingTextInputHandle, } from "@/components/ui/text-input"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { GitHubIcon } from "@/components/icons/github-icon"; import { applyAvailableAddProjectHosts, backAddProjectPage, @@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) { } function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] { - if (method === "github") return Github; + if (method === "github") return GitHubIcon; if (method === "browse") return FolderOpen; if (method === "new-directory") return FolderPlus; return Search; @@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { ? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}` : repository.nameWithOwner, subtitle: repository.description, - icon: Github, + icon: GitHubIcon, testID: `add-project-flow-repository-${repository.id}`, select: () => setState((current) => openGithubLocationPage(current, page.hostId, repository)), diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 5f467785d7..9705f3be15 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -397,18 +397,6 @@ export function SplitContainer({ }, [workspaceKey], ); - const handleCreateExplorerTab = useCallback( - () => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }), - [explorerSidebarPaneId, onCreateNewTab], - ); - const handleMoveExplorerTabToMain = useCallback( - (tabId: string) => { - if (layout.focusedPaneId) { - onMoveTabToPane(tabId, layout.focusedPaneId); - } - }, - [layout.focusedPaneId, onMoveTabToPane], - ); const splitRoot = useMemo( () => resolveSplitContainerRoot({ @@ -725,8 +713,19 @@ export function SplitContainer({ closingTabIds={closingTabIds} onSelectTab={onSelectTabInPane} onCloseTab={onCloseTab} - onCreateNewTab={handleCreateExplorerTab} - onMoveTabToMain={handleMoveExplorerTabToMain} + onCreateNewTab={onCreateNewTab} + hoveredCloseTabKey={hoveredCloseTabKey} + setHoveredCloseTabKey={setHoveredCloseTabKey} + onCopyResumeCommand={onCopyResumeCommand} + onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} + onCopyFilePath={onCopyFilePath} + onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} + onCloseTabsToLeft={onCloseTabsToLeft} + onCloseTabsToRight={onCloseTabsToRight} + onCloseOtherTabs={onCloseOtherTabs} + onExitFocusMode={onExitFocusMode} buildPaneContentModel={buildPaneContentModel} onReorderTabsInPane={onReorderTabsInPane} activeDragTabId={activeDragTabId} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 0300dd518c..e15a00018e 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1921,10 +1921,15 @@ const styles = StyleSheet.create((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius["2xl"], - paddingVertical: { + paddingTop: { xs: theme.spacing[2], md: theme.spacing[4], }, + // The button row bleeds 6px horizontally, so match its corner inset at the bottom. + paddingBottom: { + xs: theme.spacing[2], + md: theme.spacing[3], + }, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[4], diff --git a/packages/app/src/panels/new-tab-panel.tsx b/packages/app/src/panels/new-tab-panel.tsx index cdafafabed..da1039a980 100644 --- a/packages/app/src/panels/new-tab-panel.tsx +++ b/packages/app/src/panels/new-tab-panel.tsx @@ -137,6 +137,7 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement { serverId, purpose: host === "explorer" ? "supporting" : "primary", host, + surface: "panel", }); const itemsById = useMemo( () => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])), diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts index 0a91aad995..c06a87864d 100644 --- a/packages/app/src/panels/panel-manifest.test.ts +++ b/packages/app/src/panels/panel-manifest.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest"; +import { + getPanelManifest, + panelCanLaunchInPane, + panelResourceKey, + panelSupportsHost, +} from "@/panels/panel-manifest"; describe("panel manifest", () => { it("answers host support without React panel registration", () => { @@ -13,6 +18,23 @@ describe("panel manifest", () => { expect(panelSupportsHost("setup", "explorer")).toBe(false); }); + it("lets navigation tabs hide their close button without losing their close action", () => { + expect(getPanelManifest("files").showCloseButton).toBe(false); + expect(getPanelManifest("changes_tree").showCloseButton).toBe(false); + expect(getPanelManifest("agent").showCloseButton).toBe(true); + expect(getPanelManifest("terminal").showCloseButton).toBe(true); + expect(getPanelManifest("file").showCloseButton).toBe(true); + expect(getPanelManifest("working_diff").showCloseButton).toBe(true); + }); + + it("hides a singleton launch only when it already exists in the destination pane", () => { + expect(panelCanLaunchInPane("files", ["files"])).toBe(false); + expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false); + expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true); + expect(panelCanLaunchInPane("files", [])).toBe(true); + expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true); + }); + it("keeps durable resource identity separate from transient target input", () => { expect( panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }), diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts index c608340d09..4122da80f1 100644 --- a/packages/app/src/panels/panel-manifest.ts +++ b/packages/app/src/panels/panel-manifest.ts @@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer"; export interface PanelManifest { kind: K; supportedHosts: readonly PaneHost[]; + showCloseButton: boolean; + singleton: boolean; resourceKey(target: Extract): string; } @@ -16,62 +18,86 @@ const manifests = { new_tab: { kind: "new_tab", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "new_tab", }, draft: { kind: "draft", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.draftId, }, agent: { kind: "agent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.agentId, }, provider_subagent: { kind: "provider_subagent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`, }, terminal: { kind: "terminal", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.terminalId, }, browser: { kind: "browser", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.browserId, }, changes_tree: { kind: "changes_tree", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "changes_tree", }, files: { kind: "files", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "files", }, pull_request: { kind: "pull_request", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "pull_request", }, file: { kind: "file", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.path, }, working_diff: { kind: "working_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "working_diff", }, plugin: { kind: "plugin", // Plugin targets are narrowed by the target-aware plugin panel capability resolver. supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.context === "agent" ? `${target.pluginId}:${target.panelId}:agent:${target.agentId}` @@ -80,11 +106,15 @@ const manifests = { setup: { kind: "setup", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.workspaceId, }, commit_diff: { kind: "commit_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.sha, }, } satisfies PanelManifestByKind; @@ -97,6 +127,13 @@ export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHo return getPanelManifest(kind).supportedHosts.includes(host); } +export function panelCanLaunchInPane( + kind: WorkspaceTabTarget["kind"], + panePanelKinds: readonly WorkspaceTabTarget["kind"][], +): boolean { + return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind); +} + export function panelResourceKey(target: WorkspaceTabTarget): string { const manifest = getPanelManifest(target.kind); return `${target.kind}:${manifest.resourceKey(target as never)}`; diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx deleted file mode 100644 index a9b7656963..0000000000 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { ArrowLeftToLine, Plus, X } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import Animated from "react-native-reanimated"; -import { SortableInlineList } from "@/components/sortable-inline-list"; -import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout"; -import type { - DraggableListDragHandleProps, - DraggableRenderItemInfo, -} from "@/components/draggable-list.types"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome"; -import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; -import { - WorkspaceTabIcon, - WorkspaceTabPresentationResolver, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - useWorkspaceTabLaunchCatalog, - type WorkspaceTabLaunchItem, -} from "@/workspace-tabs/launcher"; -import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; -import type { Theme } from "@/styles/theme"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { - HorizontalScrollBoundaryShades, - useHorizontalScrollBoundary, -} from "@/components/ui/horizontal-scroll-boundary"; - -const TAB_GAP = 4; -const TAB_DROP_INDICATOR_WIDTH = 4; - -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface ExplorerSidebarTabRailProps { - paneId: string; - tabs: WorkspaceDesktopTabRowItem[]; - normalizedServerId: string; - normalizedWorkspaceId: string; - activeDragTabId: string | null; - tabDropPreviewIndex: number | null; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; - onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void; - trailingAccessory?: ReactNode; -} - -function tabKey(item: WorkspaceDesktopTabRowItem): string { - return `${item.tab.key}:${item.tab.kind}`; -} - -function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop { - return "surfaceSidebar"; -} - -function ExplorerSidebarTab({ - item, - isDragging, - dragHandleProps, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - normalizedServerId, - normalizedWorkspaceId, -}: { - item: WorkspaceDesktopTabRowItem; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onMoveTabToMain: (tabId: string) => void; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); - const handlePress = useCallback( - () => onNavigateTab(item.tab.tabId), - [item.tab.tabId, onNavigateTab], - ); - const handleHoverIn = useCallback(() => setHovered(true), []); - const handleHoverOut = useCallback(() => setHovered(false), []); - const handleClose = useCallback(() => { - void onCloseTab(item.tab.tabId); - }, [item.tab.tabId, onCloseTab]); - const handleMoveToMain = useCallback( - () => onMoveTabToMain(item.tab.tabId), - [item.tab.tabId, onMoveTabToMain], - ); - const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main"); - const moveToMainLeading = useMemo( - () => , - [], - ); - const closeLeading = useMemo(() => , []); - const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]); - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - - - - - - {presentation.label} - - - - - {presentation.tooltip} - - - - {canMoveToMain ? ( - - {t("workspace.tabs.menu.moveToMain")} - - ) : null} - {canMoveToMain ? : null} - - {t("workspace.tabs.menu.close")} - - - - ), - [ - accessibilityState, - dragHandleProps, - handleHoverIn, - handleHoverOut, - handleClose, - handleMoveToMain, - handlePress, - hovered, - isDragging, - item, - canMoveToMain, - closeLeading, - moveToMainLeading, - t, - ], - ); - - return ( - - {renderPresentation} - - ); -} - -function CatalogIcon({ - Icon, - color = "", -}: { - Icon: ComponentType; - color?: string; -}) { - return ; -} - -const ThemedCatalogIcon = withUnistyles(CatalogIcon); -const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); -const ThemedPlus = withUnistyles(Plus); -const ThemedX = withUnistyles(X); - -function ExplorerSidebarConfigurationItem({ - item, - paneId, - tab, - onCloseTab, -}: { - item: WorkspaceTabLaunchItem; - paneId: string; - tab: WorkspaceTabDescriptor | null; - onCloseTab: (tabId: string) => Promise | void; -}) { - const leading = useMemo(() => { - return item.Icon ? : null; - }, [item.Icon]); - const handleSelect = useCallback(() => { - if (tab) { - void onCloseTab(tab.tabId); - return; - } - item.launch({ kind: "open", paneId }); - }, [item, onCloseTab, paneId, tab]); - - return ( - - {item.label} - - ); -} - -function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean { - return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target); -} - -export function ExplorerSidebarTabRail({ - paneId, - tabs, - normalizedServerId, - normalizedWorkspaceId, - activeDragTabId, - tabDropPreviewIndex, - onNavigateTab, - onCloseTab, - onCreateNewTab, - onMoveTabToMain, - onReorderTabs, - trailingAccessory, -}: ExplorerSidebarTabRailProps) { - const scrollBoundary = useHorizontalScrollBoundary(); - const { t } = useTranslation(); - const groups = useWorkspaceTabLaunchCatalog({ - serverId: normalizedServerId, - purpose: "supporting", - host: "explorer", - }); - const singletonConfigurationItems = useMemo( - () => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null), - [groups], - ); - const newTabLeading = useMemo(() => , []); - const handleDragEnd = useCallback( - (nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)), - [onReorderTabs], - ); - const getTabDragData = useCallback( - (item: WorkspaceDesktopTabRowItem) => ({ - kind: "workspace-tab" as const, - paneId, - tabId: item.tab.tabId, - }), - [paneId], - ); - const renderTab = useCallback( - ({ - item, - index, - dragHandleProps, - isActive, - }: DraggableRenderItemInfo) => { - const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index; - const showAfter = - activeDragTabId !== null && - tabDropPreviewIndex === tabs.length && - index === tabs.length - 1; - return ( - - {showBefore ? : null} - - {showAfter ? : null} - - ); - }, - [ - activeDragTabId, - normalizedServerId, - normalizedWorkspaceId, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - tabDropPreviewIndex, - tabs.length, - ], - ); - - return ( - - - - - - - - - {trailingAccessory ? ( - {trailingAccessory} - ) : null} - - - - {t("workspace.tabs.actions.newTab")} - - - {singletonConfigurationItems.map((item) => ( - catalogItemMatchesTab(item, tab))?.tab ?? null} - onCloseTab={onCloseTab} - /> - ))} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - track: { - minWidth: 0, - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - backgroundColor: theme.colors.surfaceSidebar, - flexDirection: "row", - alignItems: "center", - }, - scrollContainer: { - flex: 1, - minWidth: 0, - alignSelf: "stretch", - }, - scrollContent: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: EXPLORER_TAB_RAIL_INSET, - }, - trailingAccessory: { - marginRight: 4, - }, - tabSlot: { - position: "relative", - marginHorizontal: TAB_GAP / 2, - }, - tab: { - height: HEADER_CONTROL_HEIGHT, - maxWidth: 180, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - userSelect: "none", - }, - tabHovered: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabLabel: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - userSelect: "none", - }, - tabLabelActive: { - color: theme.colors.foreground, - }, - tabDragging: { - opacity: 0.3, - }, - dropIndicator: { - position: "absolute", - top: theme.spacing[0.5], - bottom: theme.spacing[0.5], - width: TAB_DROP_INDICATOR_WIDTH, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, - zIndex: 10, - pointerEvents: "none", - }, - dropIndicatorBefore: { - left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - dropIndicatorAfter: { - right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - tooltipText: { - color: theme.colors.popoverForeground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 389d37c821..175be3695b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; -import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + WorkspaceDesktopTabsRow, + type WorkspaceDesktopTabRowItem, + type WorkspaceDesktopTabsRowProps, +} from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SplitPane } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; -interface ExplorerSidebarDockProps { +interface ExplorerSidebarDockProps extends Pick< + WorkspaceDesktopTabsRowProps, + | "setHoveredCloseTabKey" + | "onCopyResumeCommand" + | "onCopyAgentId" + | "onCopyTerminalId" + | "onCopyFilePath" + | "onReloadAgent" + | "onRenameTab" + | "onCreateNewTab" + | "onExitFocusMode" +> { + hoveredCloseTabKey: string | null; + onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; pane: SplitPane; uiTabs: WorkspaceTab[]; normalizedServerId: string; @@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps { tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; buildPaneContentModel: (input: { paneId: string; @@ -48,7 +64,18 @@ export function ExplorerSidebarDock({ onSelectTab, onCloseTab, onCreateNewTab, - onMoveTabToMain, + hoveredCloseTabKey, + setHoveredCloseTabKey, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onExitFocusMode, onReorderTabsInPane, buildPaneContentModel, headerAction, @@ -61,10 +88,10 @@ export function ExplorerSidebarDock({ tabs.map((tab) => ({ tab, isActive: tab.tabId === activeTabId, - isCloseHovered: false, + isCloseHovered: hoveredCloseTabKey === tab.key, isClosingTab: closingTabIds.has(tab.tabId), })), - [activeTabId, closingTabIds, tabs], + [activeTabId, closingTabIds, hoveredCloseTabKey, tabs], ); const handleSelectTab = useCallback( (tabId: string) => onSelectTab(pane.id, tabId), @@ -80,28 +107,59 @@ export function ExplorerSidebarDock({ [onReorderTabsInPane, pane.id], ); + const handleCloseTabsToLeft = useCallback( + (tabId: string) => onCloseTabsToLeft(tabId, tabs), + [onCloseTabsToLeft, tabs], + ); + const handleCloseTabsToRight = useCallback( + (tabId: string) => onCloseTabsToRight(tabId, tabs), + [onCloseTabsToRight, tabs], + ); + const handleCloseOtherTabs = useCallback( + (tabId: string) => onCloseOtherTabs(tabId, tabs), + [onCloseOtherTabs, tabs], + ); + return ( - + + + + {headerAction ? {headerAction} : null} @@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({ }, tabRail: { position: "relative", + flexDirection: "row", + alignItems: "center", flexShrink: 0, backgroundColor: theme.colors.surfaceSidebar, }, + tabRow: { + flex: 1, + minWidth: 0, + }, + headerAction: { + marginRight: theme.spacing[1], + }, tabRailDivider: { position: "absolute", right: 0, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bbb0561130..f1806ede34 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -63,6 +63,8 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { PaneHost } from "@/panels/panel-manifest"; +import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; @@ -107,7 +109,7 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MODIFIED_DOT_SIZE = 8; -const TAB_MIN_WIDTH = 96; +const TAB_MIN_WIDTH = 64; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; @@ -209,7 +211,12 @@ function TabLabelMeasurement({ ); } +type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][]; + interface WorkspaceNewTabButtonProps { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; serverId: string; paneId?: string; shortcutKeys: ShortcutKey[][] | null; @@ -217,6 +224,9 @@ interface WorkspaceNewTabButtonProps { } function WorkspaceNewTabButton({ + panePanelKinds, + host, + launchPurpose, serverId, paneId, shortcutKeys, @@ -237,8 +247,9 @@ function WorkspaceNewTabButton({ @@ -248,6 +259,9 @@ function WorkspaceNewTabButton({ } function WorkspacePaneToolbarActions({ + panePanelKinds, + host, + launchPurpose, showNewTabButton, showSplitActions, showMaximizeAction, @@ -259,6 +273,9 @@ function WorkspacePaneToolbarActions({ onSplitDown, onTogglePaneMaximized, }: { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; showNewTabButton: boolean; showSplitActions: boolean; showMaximizeAction: boolean; @@ -297,6 +314,9 @@ function WorkspacePaneToolbarActions({ {showNewTabButton ? ( width === right[index]); } -interface WorkspaceDesktopTabsRowProps { +export interface WorkspaceDesktopTabsRowProps { + host?: PaneHost; + launchPurpose?: WorkspaceTabLaunchPurpose; paneId?: string; isFocused?: boolean; + ownsKeyboardShortcuts?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; @@ -988,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { } function ResolvedWorkspaceDesktopTabsRow({ + host = "main", + launchPurpose = "primary", paneId, isFocused = false, + ownsKeyboardShortcuts = isFocused, tabs, normalizedServerId, normalizedWorkspaceId, @@ -1059,6 +1085,8 @@ function ResolvedWorkspaceDesktopTabsRow({ [exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions], ); + const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]); + const fallbackTabLabels = useMemo( () => ({ newTab: t("workspace.tabs.actions.newTab"), @@ -1203,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({ const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isFocused) return false; + if (!ownsKeyboardShortcuts) return false; if (action.id === "workspace.tab.menu.open") { createNewTab(); return true; } return false; }, - [createNewTab, isFocused], + [createNewTab, ownsKeyboardShortcuts], ); useKeyboardActionHandler({ @@ -1221,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ paneId, }), actions: ["workspace.tab.menu.open"], - enabled: isFocused, + enabled: ownsKeyboardShortcuts, priority: 200, handle: handleNewTabKeyboardAction, }); @@ -1233,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({ dragHandleProps, isActive, }: DraggableRenderItemInfo) => { - const shouldShowCloseButton = layout.closeButtonPolicy === "all"; + const shouldShowCloseButton = + layout.closeButtonPolicy === "all" && item.presentation.showCloseButton; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; @@ -1360,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("lets short labels use their content width below the old 96px floor", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 300, + tabLabelWidths: [29, 52], + metrics: { ...metrics, actionsReservedWidth: 36 }, + }); + expect(result.items).toEqual([ + { width: 64, showLabel: true }, + { width: 86, showLabel: true }, + ]); + expect(result.requiresHorizontalScrollFallback).toBe(false); }); it("sizes a single tab between the minimum and maximum from its content", () => { @@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]); + expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => { it("keeps every tab at the clickable minimum at the exact fit boundary", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 532, + viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 531, + viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 07cd56e4fb..8b499429f0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation { subtitle: string; tooltip: string; modified: boolean; + showCloseButton: boolean; titleState: "ready" | "loading"; icon: React.ComponentType; statusBucket: SidebarStateBucket | null; @@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({ subtitle: descriptor.subtitle, tooltip: descriptor.tooltip, modified: attributes.modified, + showCloseButton: registration.showCloseButton, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, @@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({ tab.key, tab.kind, attributes.modified, + registration.showCloseButton, ], ); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index f58075b3a1..04b1e8f6ae 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -597,6 +597,8 @@ export const ICON_SIZE = { lg: 20, } as const; +export const ICON_STROKE_WIDTH = 1.5; + export const FONT_WEIGHT = { normal: "normal" as const, medium: "500" as const, diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 953c13e0c6..601030562f 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,6 +13,7 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; +import { getPanelManifest } from "@/panels/panel-manifest"; import type { SubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { @@ -52,6 +53,7 @@ function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPre ...data, tooltip: data.label, modified: false, + showCloseButton: getPanelManifest(data.kind).showCloseButton, icon, }; } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 8f10527d75..ea983b4bd8 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -18,7 +18,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; +import { panelCanLaunchInPane, panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; import { getPanelRegistration, type PanelIconProps, @@ -53,8 +53,6 @@ export interface WorkspaceTabLaunchItem { shortcutActionId?: string; disabled: boolean; panelKind: WorkspaceTabTarget["kind"]; - /** The fixed view this item can toggle in a configuration menu, or null for launch-only items. */ - toggleTarget: WorkspaceTabTarget | null; launch: (destination: WorkspaceTabLaunchDestination) => void; } @@ -65,6 +63,8 @@ export interface WorkspaceTabLaunchGroup { accessory?: { id: string; label: string; run: () => void }; } +const EMPTY_PANE_PANEL_KINDS: readonly WorkspaceTabTarget["kind"][] = []; + const NewTabLauncherContext = createContext(null); export function NewTabLauncherProvider({ @@ -97,8 +97,10 @@ export function useWorkspaceTabLaunchCatalog(input: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + surface: "menu" | "panel"; + panePanelKinds?: readonly WorkspaceTabTarget["kind"][]; }): readonly WorkspaceTabLaunchGroup[] { - const { serverId, purpose, host } = input; + const { serverId, purpose, host, surface, panePanelKinds = EMPTY_PANE_PANEL_KINDS } = input; const { t } = useTranslation(); const router = useRouter(); const launcher = useContext(NewTabLauncherContext); @@ -118,6 +120,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [router, serverId]); return useMemo(() => { + const isExplorerMenu = host === "explorer" && surface === "menu"; const changesPresentation = getLaunchPresentation("changes_tree"); const diffPresentation = getLaunchPresentation("working_diff"); const filesPresentation = getLaunchPresentation("files"); @@ -130,7 +133,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", - toggleTarget: null, + hidden: isExplorerMenu, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -140,7 +143,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-terminal-new", disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection(BUILT_IN_SELECTIONS.terminal), }, changes: { @@ -149,7 +151,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: changesPresentation.icon, disabled: false, panelKind: "changes_tree", - toggleTarget: BUILT_IN_SELECTIONS.changes.target, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.changes), }, @@ -160,7 +161,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-changes", disabled: false, panelKind: "working_diff", - toggleTarget: null, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.diff), }, @@ -171,7 +171,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-files", disabled: false, panelKind: "files", - toggleTarget: BUILT_IN_SELECTIONS.files.target, launch: launchSelection(BUILT_IN_SELECTIONS.files), }, browser: { @@ -181,7 +180,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-browser", disabled: false, panelKind: "browser", - toggleTarget: null, hidden: !launcher.showBrowser, launch: launchSelection(BUILT_IN_SELECTIONS.browser), }, @@ -191,7 +189,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: pullRequestPresentation.icon, disabled: false, panelKind: "pull_request", - toggleTarget: null, hidden: !launcher.showPullRequest, launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest), }, @@ -218,7 +215,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: resolvePluginIcon(panel.icon), disabled: false, panelKind: "plugin", - toggleTarget: selection.target, launch: launchSelection(selection), }); } @@ -229,7 +225,7 @@ export function useWorkspaceTabLaunchCatalog(input: { if (pluginItems.length > 0) { groups.push({ id: "plugin-panels", label: null, items: pluginItems }); } - if (profiles.length > 0) { + if (!isExplorerMenu && profiles.length > 0) { groups.push({ id: "terminal-profiles", label: t("workspace.tabs.actions.terminalProfilesMenu"), @@ -239,7 +235,6 @@ export function useWorkspaceTabLaunchCatalog(input: { terminalIconKey: getTerminalProfileIcon(profile), disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection({ kind: "terminal", profile }), })), accessory: { @@ -249,7 +244,13 @@ export function useWorkspaceTabLaunchCatalog(input: { }, }); } - return groups; + if (surface !== "menu") return groups; + return groups.flatMap((group) => { + const items = group.items.filter((item) => + panelCanLaunchInPane(item.panelKind, panePanelKinds), + ); + return items.length > 0 ? [{ ...group, items }] : []; + }); }, [ config?.terminalProfiles, editTerminalProfiles, @@ -258,6 +259,8 @@ export function useWorkspaceTabLaunchCatalog(input: { plugins, purpose, host, + surface, + panePanelKinds, serverId, t, ]); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts index a2ede20ddf..864e0ec43f 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -17,9 +17,9 @@ describe("getBuiltInLaunchOrder", () => { it("leads with companion tools in a supporting pane", () => { expect(getBuiltInLaunchOrder("supporting")).toEqual([ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts index dfed374170..5c2c77cdfe 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [ export const SUPPORTING_LAUNCH_ORDER = [ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index b5f505ea2d..e5cb02d46e 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -991,9 +991,9 @@ const x = 1; }); it("does not report local-only no-track worktree commits as ahead of origin", async () => { - setupRemoteTrackingMain(repoDir, tempDir); - commitFile(repoDir, "second.txt", "second\n", "second commit"); - execFileSync("git", ["push"], { cwd: repoDir }); + // This case needs a remote base ref, not a second clone or extra base history. + execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", @@ -1007,6 +1007,7 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, + hasRemote: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, diff --git a/packages/website/src/components/mockup/titlebar.tsx b/packages/website/src/components/mockup/titlebar.tsx index 6f3b91032f..de99f4db76 100644 --- a/packages/website/src/components/mockup/titlebar.tsx +++ b/packages/website/src/components/mockup/titlebar.tsx @@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) { {shipping ? ( <> - Merge PR (squash) + Merge PR ) : ( <> From 5293ddac3f17f35ea090b292447ec0498edafafe Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:29:16 +0000 Subject: [PATCH 002/281] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index f42dbcdbf3..13a3e1453b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-L1gb8LCZKPnK6T9XabQ6ctrvyUJnM+TPUCAayssVPMg= +sha256-NdlgVA9tBFmAFDkyo6pRmMiZSD3aXwGbkAHNyqCOVNI= From 8aec0787d2bb095919b22dedcd616476edac4e99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 10:12:01 +0100 Subject: [PATCH 003/281] fix(app): keep chat reading position stable while scrolling (#5945) * fix(app): keep chat reading position stable while scrolling Prepend restoration froze the reading offset while wheel input and delayed image measurements changed layout. Give the viewport one reading anchor, preserve concurrent user movement, and apply virtual row measurements before paint. Suspend automatic pagination while explicit message navigation settles. * fix(app): type the reading anchor scroll intent explicitly * fix(app): retain the reading anchor through virtualization startup Keep the reader pinned while the first virtual range mounts. Strengthen the scroll regression detector to compare shared rows when the original anchor unmounts, and consume fixture replies only when they are emitted. * test(app): distinguish delayed wheel input from idle scroll drift CI frames can arrive long after their wheel events. Start idle assertions after the input driver finishes, and account for an image entered by wheel input expanding below the new reading line. Replayed all six CI captures without false positives while retaining the original 76 baseline jumps. * test(app): bound image growth allowance to displaced reading rows Require the entered image top to follow wheel input before attributing movement of a later row to its growth. Calibrate the detector against simultaneous viewport jumps and jumps of the growing row itself. * test(app): clamp scroll expectations at the history boundary --- .../agent-timeline-scroll-smoothness.spec.ts | 81 ++++ .../helpers/timeline-scroll-smoothness.ts | 430 ++++++++++++++++++ .../src/agent-stream/reading-anchor.test.ts | 93 ++++ .../app/src/agent-stream/reading-anchor.ts | 32 ++ .../app/src/agent-stream/strategy-web.tsx | 267 +++++------ .../agent-stream/web-virtualization.test.ts | 50 -- .../src/agent-stream/web-virtualization.ts | 15 - .../providers/mock-load-test-agent.test.ts | 12 + .../agent/providers/mock-load-test-agent.ts | 17 +- 9 files changed, 773 insertions(+), 224 deletions(-) create mode 100644 packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts create mode 100644 packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts create mode 100644 packages/app/src/agent-stream/reading-anchor.test.ts create mode 100644 packages/app/src/agent-stream/reading-anchor.ts diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts new file mode 100644 index 0000000000..b45007fa0b --- /dev/null +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -0,0 +1,81 @@ +import { test } from "../support/fixtures"; +import { expect } from "@playwright/test"; +import { + findScrollJumps, + type ScrollFrame, + observeTimelinePages, + openOnlyTimelineTail, + recordUpwardTraversal, + reportScrollJumps, + scrollCadences, + withVariedTimeline, +} from "../support/helpers/timeline-scroll-smoothness"; + +test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 1000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -150, height: 150 }, + { id: "reading", top: 0, height: 100 }, + ], + anchor: "reading", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const after: ScrollFrame = { + ...before, + at: 1400, + wheelTotal: 100, + lastWheelAt: 1050, + rows: [ + { id: "image", top: -50, height: 1150 }, + { id: "reading", top: 1100, height: 100 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + expect( + findScrollJumps([ + { ...before, scrollTop: 200 }, + { + ...after, + wheelTotal: 1000, + rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), + }, + ]), + ).toEqual([]); + expect( + findScrollJumps([ + before, + { ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) }, + ]), + ).toHaveLength(1); + expect( + findScrollJumps([ + { ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] }, + { ...after, rows: [{ id: "image", top: 600, height: 1150 }] }, + ]), + ).toHaveLength(1); +}); + +for (const cadence of scrollCadences) { + test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + await withVariedTimeline(async (agent, newestPrompt) => { + const pages = observeTimelinePages(page, agent.agentId); + await openOnlyTimelineTail(page, agent, newestPrompt, pages); + const frames = await recordUpwardTraversal(page, cadence, testInfo); + await reportScrollJumps(page, testInfo, frames, pages); + }); + }); +} diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts new file mode 100644 index 0000000000..63ef1c10a0 --- /dev/null +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -0,0 +1,430 @@ +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination"; + +export const scrollCadences = [ + { name: "slow", delta: 160, intervalMs: 50, steps: 1000 }, + { name: "medium", delta: 480, intervalMs: 32, steps: 400 }, + { name: "fast", delta: 1200, intervalMs: 16, steps: 180 }, +] as const; + +type Cadence = (typeof scrollCadences)[number]; +interface RowFrame { + id: string; + top: number; + height: number; +} +export interface ScrollFrame { + at: number; + scrollTop: number; + scrollHeight: number; + viewportHeight: number; + virtualized: boolean; + loading: boolean; + rows: RowFrame[]; + anchor: string | null; + wheelTotal: number; + lastWheelAt: number; + inputFinishedAt: number | null; + imageLoads: number; + mounted: number; + unmounted: number; +} +interface Recording { + frames: ScrollFrame[]; + finishInput(): void; + stop(): void; +} +interface TimelinePage { + direction: string; + count: number; + hasOlder: boolean; + at: number; +} + +/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */ +export async function withVariedTimeline( + run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise, +): Promise { + const timers = new Set>(); + const server = createServer((request, response) => { + const portrait = request.url?.includes("portrait"); + const timer = setTimeout( + () => { + timers.delete(timer); + response.writeHead(200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }); + response.end( + `Delayed ${portrait ? "portrait" : "landscape"}`, + ); + }, + portrait ? 900 : 350, + ); + timers.add(timer); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Image server did not listen"); + const imageUrl = `http://127.0.0.1:${address.port}`; + let agent: MockAgentWorkspace | undefined; + try { + agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-scroll-smoothness-", + title: "Varied timeline scroll reproduction", + featureValues: { + mockAssistantResponses: Array.from({ length: 70 }, (_, turn) => + [ + `## Review of turn ${turn}`, + "A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.", + "| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |", + `![Delayed landscape](${imageUrl}/${turn}/landscape.svg)`, + "```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```", + "> Keep reading while content above the viewport changes.", + "- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item", + `![Delayed portrait](${imageUrl}/${turn}/portrait.svg)`, + "Review complete. The next prompt starts a separate turn.", + ].join("\n\n"), + ), + }, + }); + for (let turn = 0; turn < 70; turn += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await run( + agent, + `Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`, + ); + } finally { + await agent?.cleanup(); + for (const timer of timers) clearTimeout(timer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] { + const pages: TimelinePage[] = []; + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + const envelope = JSON.parse(String(payload)); + const message = envelope.message; + if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId) + return; + pages.push({ + direction: message.payload.direction, + count: message.payload.entries.length, + hasOlder: message.payload.hasOlder, + at: Date.now(), + }); + }); + }); + return pages; +} + +export async function openOnlyTimelineTail( + page: Page, + agent: MockAgentWorkspace, + newestPrompt: string, + pages: TimelinePage[], +): Promise { + await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true)); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, newestPrompt.trim()); + await expect + .poll(() => pages.filter((entry) => entry.direction === "tail").length) + .toBeGreaterThanOrEqual(1); + + expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true }); + expect(pages[0].count).toBe(40); + expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1); + expect( + pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0), + ).toBe(true); + await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0); + // Start each cadence after the tail's initial image loads and bottom anchoring. + await page.waitForTimeout(1500); + expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0); +} + +export async function recordUpwardTraversal( + page: Page, + cadence: Cadence, + testInfo: TestInfo, +): Promise { + const stopTrace = await traceScrollIfRequested(page, testInfo, cadence); + const timeline = page.getByTestId("agent-chat-scroll"); + await timeline.hover(); + await timeline.evaluate((viewport) => { + const scroll = viewport as HTMLElement; + const frames: ScrollFrame[] = []; + let frame = 0; + let wheelTotal = 0; + let lastWheelAt = -Infinity; + let inputFinishedAt: number | null = null; + let imageLoads = 0; + let mounted = 0; + let unmounted = 0; + let previousIds = new Set(); + const wheel = (event: WheelEvent) => { + wheelTotal += -event.deltaY; + lastWheelAt = performance.now(); + }; + const imageLoad = (event: Event) => { + if (event.target instanceof HTMLImageElement) imageLoads += 1; + }; + scroll.addEventListener("wheel", wheel, { passive: true }); + scroll.addEventListener("load", imageLoad, true); + const observed = new Set(); + const capture = (replaceLast: boolean) => { + const rect = scroll.getBoundingClientRect(); + const elements = Array.from(scroll.querySelectorAll("[data-history-row-id]")); + const current = new Set([scroll, ...elements]); + if (scroll.firstElementChild) current.add(scroll.firstElementChild); + for (const element of observed) { + if (!current.has(element)) { + resizeObserver.unobserve(element); + observed.delete(element); + } + } + for (const element of current) { + if (!observed.has(element)) { + resizeObserver.observe(element); + observed.add(element); + } + } + const rows = elements.map((row) => { + const box = row.getBoundingClientRect(); + return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height }; + }); + const ids = new Set(rows.map((row) => row.id)); + mounted += [...ids].filter((id) => !previousIds.has(id)).length; + unmounted += [...previousIds].filter((id) => !ids.has(id)).length; + previousIds = ids; + // Preserve the row crossing the reading line. Rows below an expanding + // visible image legitimately move even when the reading position is stable. + const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8); + const snapshot: ScrollFrame = { + at: performance.now(), + scrollTop: scroll.scrollTop, + scrollHeight: scroll.scrollHeight, + viewportHeight: rect.height, + rows, + anchor: anchor?.id ?? null, + virtualized: scroll.id.includes("virtualized"), + loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'), + wheelTotal, + lastWheelAt, + inputFinishedAt, + imageLoads, + mounted, + unmounted, + }; + if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot; + else frames.push(snapshot); + }; + // rAF precedes ResizeObserver. Refresh that frame after resize delivery so + // it represents the final layout, not geometry repaired before the paint. + const resizeObserver = new ResizeObserver(() => capture(true)); + const sample = () => { + capture(false); + frame = requestAnimationFrame(sample); + }; + const recording: Recording = { + frames, + finishInput() { + inputFinishedAt = performance.now(); + }, + stop() { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + scroll.removeEventListener("wheel", wheel); + scroll.removeEventListener("load", imageLoad, true); + }, + }; + Reflect.set(window, "__timelineScrollRecording", recording); + const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__"); + if (typeof reset === "function") reset(); + sample(); + }); + // No settling between inputs: exercise wheel input overlapping prepend and measurement. + for (let step = 0; step < cadence.steps; step += 1) { + await page.mouse.wheel(0, -cadence.delta); + await page.waitForTimeout(cadence.intervalMs); + } + await page.evaluate(() => { + (Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput(); + }); + await page.waitForTimeout(1500); + await stopTrace(); + return page.evaluate(() => { + const recording = Reflect.get(window, "__timelineScrollRecording") as Recording; + recording.stop(); + return recording.frames; + }); +} + +export function findScrollJumps(frames: ScrollFrame[]) { + return frames.flatMap((current, index) => { + const previous = frames[index - 1]; + if (!previous?.anchor) return []; + // Wheel input can move the reading line onto an image before it expands. + // Follow that intended row, not text now below the image. + const inputDelta = current.wheelTotal - previous.wheelTotal; + const availableScroll = Math.min(inputDelta, previous.scrollTop); + const readingLine = 8 - availableScroll; + const currentRows = new Map(current.rows.map((row) => [row.id, row])); + const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine); + const intendedHeight = intendedRow && currentRows.get(intendedRow.id)?.height; + const anchor = previous.anchor; + const before = currentRows.has(anchor) + ? previous.rows.find((row) => row.id === anchor)! + : previous.rows + .filter((row) => currentRows.has(row.id)) + .sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0]; + if (!before) { + throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`); + } + const after = currentRows.get(before.id)!; + const movement = after.top - before.top; + // A busy main thread can deliver wheel movement hundreds of milliseconds + // after its event. Only assert idle stability after the driver stops input. + const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; + const recent = frames.findLast((frame) => frame.at <= previous.at - 100); + const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); + // Growth displaces only later rows. It never grants extra movement to the + // image itself, and its top must still follow the input budget. + const enteredRowGrowth = + inputDelta > 0 && + intendedRow && + intendedHeight !== undefined && + before.top >= intendedRow.top + intendedRow.height && + Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32 + ? Math.max(0, intendedHeight - intendedRow.height) + : 0; + const excessForward = movement > wheelBudget + enteredRowGrowth + 32; + // Upward wheel input moves the same text DOWN the viewport. A negative move + // is a reversal, independent of legitimate scrollTop compensation on prepend. + if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return []; + return [ + { + frame: index, + at: current.at, + row: before.id, + movement, + idle, + excessForward, + resizedRows: current.rows.flatMap((row) => { + const old = previous.rows.find((candidate) => candidate.id === row.id); + return old && old.height !== row.height + ? [{ id: row.id, before: old.height, after: row.height }] + : []; + }), + loading: current.loading || previous.loading, + virtualized: current.virtualized, + heightChange: current.scrollHeight - previous.scrollHeight, + imageLoads: current.imageLoads - previous.imageLoads, + frameGapMs: current.at - previous.at, + }, + ]; + }); +} + +export async function reportScrollJumps( + page: Page, + testInfo: TestInfo, + frames: ScrollFrame[], + pages: TimelinePage[], +): Promise { + const jumps = findScrollJumps(frames); + const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []); + const gaps = frames + .slice(1) + .map((frame, index) => frame.at - frames[index].at) + .sort((a, b) => a - b); + const summary = { + browserTimeOrigin: await page.evaluate(() => performance.timeOrigin), + frames: frames.length, + pages, + jumpCount: jumps.length, + worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)), + frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)], + frameGapMaxMs: gaps.at(-1), + imageLoads: frames.at(-1)?.imageLoads, + mounted: frames.at(-1)?.mounted, + unmounted: frames.at(-1)?.unmounted, + jumps, + }; + await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2)); + await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames)); + await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react)); + await testInfo.attach("scroll-summary", { + body: JSON.stringify(summary, null, 2), + contentType: "application/json", + }); + await testInfo.attach("scroll-frames", { + body: JSON.stringify(frames), + contentType: "application/json", + }); + await testInfo.attach("scroll-react-profile", { + body: JSON.stringify(react), + contentType: "application/json", + }); + await testInfo.attach("scroll-end", { body: await page.screenshot(), contentType: "image/png" }); + console.log( + `[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`, + ); + expect( + pages.filter((entry) => entry.direction === "before").length, + "must exercise multiple older pages", + ).toBeGreaterThanOrEqual(2); + expect( + frames.some((frame) => frame.virtualized), + "must exercise virtualization", + ).toBe(true); + expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0); + expect( + jumps, + "visible text must not reverse direction while scrolling upward or move after input stops", + ).toEqual([]); +} + +async function traceScrollIfRequested( + page: Page, + testInfo: TestInfo, + cadence: Cadence, +): Promise<() => Promise> { + if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {}; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Tracing.start", { + categories: + "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler", + transferMode: "ReturnAsStream", + }); + return async () => { + const completed = new Promise((resolve) => { + cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!)); + }); + await cdp.send("Tracing.end"); + const stream = await completed; + const chunks: string[] = []; + for (;;) { + const chunk = await cdp.send("IO.read", { handle: stream }); + chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data); + if (chunk.eof) break; + } + await cdp.send("IO.close", { handle: stream }); + await cdp.detach(); + const path = testInfo.outputPath("scroll-chromium-trace.json"); + await writeFile(path, chunks.join("")); + await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" }); + }; +} diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts new file mode 100644 index 0000000000..19b6bc9e31 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { createReadingAnchor } from "./reading-anchor"; + +describe("reading anchor", () => { + const rows = [ + { id: "above", top: 0, height: 500 }, + { id: "reading", top: 500, height: 500 }, + { id: "below", top: 1000, height: 500 }, + ]; + + it("preserves wheel movement during an asynchronous prepend", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2440); + expect( + anchor.reconcile( + 2280, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2280); + }); + + it("ignores growth below the reader and compensates growth above", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600); + expect( + anchor.reconcile(600, [ + { ...rows[0]!, height: 1000 }, + { ...rows[1]!, top: 1000 }, + { ...rows[2]!, top: 1500 }, + ]), + ).toBe(1100); + }); + + it("leaves expansion of the row being read in place", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]), + ).toBe(600); + }); + + it("keeps the existing reader through prepend measurements until the user scrolls", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]); + const prepended = [ + { id: "new", top: 2000, height: 57 }, + { id: "reading", top: 2057, height: 100 }, + ]; + expect(anchor.reconcile(0, prepended)).toBe(2009); + expect(anchor.getRowId()).toBe("reading"); + const measured = [ + { id: "new", top: 2000, height: 100 }, + { id: "reading", top: 2100, height: 100 }, + ]; + expect(anchor.reconcile(2009, measured)).toBe(2052); + expect(anchor.getRowId()).toBe("reading"); + expect(anchor.reconcile(2000, measured, true)).toBe(2000); + expect(anchor.getRowId()).toBe("new"); + }); + + it("retains the reader while the virtualizer initializes its mounted range", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reconcile(600, [rows[2]!]); + expect(anchor.getRowId()).toBe("reading"); + expect( + anchor.reconcile( + 600, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + ), + ).toBe(2600); + }); + + it("releases the old reading position for explicit navigation", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reset(); + expect(anchor.getRowId()).toBeNull(); + expect( + anchor.reconcile( + 0, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(0); + }); +}); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts new file mode 100644 index 0000000000..31b965f134 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -0,0 +1,32 @@ +interface RowGeometry { + id: string; + top: number; + height: number; +} + +// Store content coordinates, never a frozen viewport offset: user scrolling must +// survive a layout correction, including input delivered while a page is loading. +export function createReadingAnchor() { + let anchor: { id: string; top: number } | null = null; + return { + getRowId: () => anchor?.id ?? null, + reset() { + anchor = null; + }, + reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + const previous = anchor && rows.find((row) => row.id === anchor?.id); + // The first virtualized commit can precede its mounted range. Keep the + // pinned reader until it mounts, rather than adopting an unrelated row. + if (anchor && !previous && !userScrolled) return scrollTop; + const correctedTop = scrollTop + (previous && anchor ? previous.top - anchor.top : 0); + // A prepend can expose the bottom of an estimated row above the reader. + // Do not transfer ownership to it until the user moves the reading position. + const next = + previous && !userScrolled + ? previous + : rows.find((row) => row.top + row.height > correctedTop + 8); + anchor = next ? { id: next.id, top: next.top } : null; + return correctedTop; + }, + }; +} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index c798c374d4..eee4557ab7 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -9,10 +9,12 @@ import React, { } from "react"; import { defaultRangeExtractor, + observeElementOffset, measureElement as measureVirtualElement, useVirtualizer, type Range as VirtualRange, } from "@tanstack/react-virtual"; +import { flushSync } from "react-dom"; import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -20,10 +22,8 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; -import { - estimateStreamItemHeight, - shouldAdjustScrollForVirtualRowResize, -} from "./web-virtualization"; +import { estimateStreamItemHeight } from "./web-virtualization"; +import { createReadingAnchor } from "./reading-anchor"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; @@ -49,12 +49,6 @@ interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } -interface HistoryStartPrependAnchor { - progressKey: string; - rowId: string; - viewportOffset: number; -} - type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; @@ -76,15 +70,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null { - for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { - if (element.dataset.historyRowId === rowId) { - return element; - } - } - return null; -} - const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", @@ -348,14 +333,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); - const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); const [historyStartPaginationState, setHistoryStartPaginationState] = useState( createHistoryStartPaginationState, ); const historyStartPaginationStateRef = useRef(historyStartPaginationState); - const historyStartPrependAnchorRef = useRef(null); - const historyStartPrependAnchorActiveRef = useRef(false); + const readingAnchor = useMemo(createReadingAnchor, []); + const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); const lastActiveFollowOutputLayoutRef = useRef(null); const lastObservedViewportGeometryRef = useRef(null); @@ -392,11 +376,28 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange) => { const visible = defaultRangeExtractor(range); - if (!chatFindRowIndexes) return visible; - return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right); + const anchorIndex = segments.historyVirtualized.findIndex( + (row) => row.id === readingAnchor.getRowId(), + ); + return [ + ...new Set([ + ...visible, + ...(chatFindRowIndexes ?? []), + ...(anchorIndex < 0 ? [] : [anchorIndex]), + ]), + ].sort((left, right) => left - right); }, - [chatFindRowIndexes], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], ); + const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); + const observeVirtualOffset = useCallback((instance, onOffset) => { + virtualOffsetListener.current = onOffset; + const unsubscribe = observeElementOffset(instance, onOffset); + return () => { + virtualOffsetListener.current = null; + unsubscribe?.(); + }; + }, []); const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, @@ -404,31 +405,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; + const measured = row && measuredRowHeights.current.get(row.id); + const width = contentRef.current?.getBoundingClientRect().width; + if (measured && measured.width === width) return measured.height; return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; }, - measureElement: measureVirtualElement, + observeElementOffset: observeVirtualOffset, rangeExtractor, scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, - useAnimationFrameWithResizeObserver: true, overscan: 8, }); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current, - rowStart: item.start, - scrollOffset, - remainingDistanceFromBottom: remainingDistance, - bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX, - }); - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); + // Scroll correction belongs to the viewport's committed layout. The virtualizer + // otherwise writes scrollTop before React applies its new row positions. + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { @@ -442,7 +431,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool distanceFromHistoryStart: scrollContainer.scrollTop, hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && bottomAnchorSettled, + // An explicit jump owns the landing position. Revealing another local + // page here changes the virtual index before the target has mounted. + isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); @@ -453,29 +444,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (transition.state !== previousState) { setHistoryStartPaginationState(transition.state); } - if (!isHistoryStartLoadingOperation(transition.state)) { - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; - } if (!transition.shouldLoad || olderHistoryProgressKey === null) { return; } - const scrollContainer = scrollContainerRef.current; - const contentNode = contentRef.current; - const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1); - const anchorElement = - contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null; - if (scrollContainer && anchorRow && anchorElement) { - historyStartPrependAnchorRef.current = { - progressKey: olderHistoryProgressKey, - rowId: anchorRow.id, - viewportOffset: - anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, - }; - } else { - historyStartPrependAnchorRef.current = null; - } - historyStartPrependAnchorActiveRef.current = false; const requestedProgressKey = olderHistoryProgressKey; void (async () => { const started = await onNearHistoryStart(); @@ -512,27 +483,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool setHistoryStartPaginationState(rearmed); evaluateHistoryStart(); }); - const applyHistoryStartPrependAnchor = useStableEvent(() => { - const scrollContainer = scrollContainerRef.current; - const contentNode = contentRef.current; - const anchor = historyStartPrependAnchorRef.current; - if ( - !scrollContainer || - !contentNode || - !anchor || - !historyStartPrependAnchorActiveRef.current - ) { - return; - } - const anchorElement = findHistoryRowElement(contentNode, anchor.rowId); - if (!anchorElement) { - return; - } - const viewportOffset = - anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top; - scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset; - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - }); const scheduleHistoryStartPrependSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -543,24 +493,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isSettling: () => historyStartPaginationStateRef.current.status === "settling", isLoading: () => { const input = getHistoryStartPaginationInput(); - return ( - !input || - input.isLoadingOlderHistory || - pendingVirtualRowMeasureFramesRef.current.size > 0 - ); + return !input || input.isLoadingOlderHistory; }, - onFrame: applyHistoryStartPrependAnchor, onSettle: () => { const input = getHistoryStartPaginationInput(); if (!input) { return; } - historyStartPrependAnchorActiveRef.current = false; const transition = settleHistoryStartPagination( historyStartPaginationStateRef.current, input, ); - historyStartPrependAnchorRef.current = null; applyHistoryStartPaginationTransition(transition); }, }); @@ -569,59 +512,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - useLayoutEffect(() => { - if (!isActiveRef.current) { - return; - } - const anchor = historyStartPrependAnchorRef.current; - if (!anchor || anchor.progressKey === olderHistoryProgressKey) { - return; - } - historyStartPrependAnchorActiveRef.current = true; - evaluateHistoryStart(); - applyHistoryStartPrependAnchor(); - scheduleHistoryStartPrependSettle(); - }, [ - applyHistoryStartPrependAnchor, - evaluateHistoryStart, - olderHistoryProgressKey, - scheduleHistoryStartPrependSettle, - segments.historyMounted, - segments.historyVirtualized, - virtualTotalSize, - ]); - - const measureVirtualizedRowElement = useCallback( - (node: HTMLDivElement | null) => { - if (!node) { - rowVirtualizer.measureElement(null); - return; - } - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - const existingFrame = pendingFrames.get(node); - if (existingFrame !== undefined) { - window.cancelAnimationFrame(existingFrame); - } - const frame = window.requestAnimationFrame(() => { - pendingFrames.delete(node); - if (isActiveRef.current && node.isConnected) { - rowVirtualizer.measureElement(node); - } - }); - pendingFrames.set(node, frame); - }, + // ResizeObserver runs before paint. Batch measurements and commit React's new + // positions here so a resized image cannot overlap rows for an extra frame. + // Use resizeItem exclusively; measureElement would install a second observer. + const virtualRowObserver = useMemo( + () => + new ResizeObserver((entries) => { + if (!isActiveRef.current) return; + flushSync(() => { + for (const entry of entries) { + const element = entry.target as HTMLElement; + if (!element.isConnected) continue; + rowVirtualizer.resizeItem( + Number(element.dataset.index), + measureVirtualElement(element, entry, rowVirtualizer), + ); + } + }); + }), [rowVirtualizer], ); - - useEffect(() => { - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - return () => { - for (const frame of pendingFrames.values()) { - window.cancelAnimationFrame(frame); - } - pendingFrames.clear(); - }; - }, []); + const measureVirtualizedRowElement = useCallback( + (node: HTMLDivElement | null) => { + if (!node) return; + virtualRowObserver.observe(node, { box: "border-box" }); + return () => virtualRowObserver.unobserve(node); + }, + [virtualRowObserver], + ); + useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; @@ -660,10 +579,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom(); historyStartSettleSchedulerRef.current?.cancel(); - for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) { - window.cancelAnimationFrame(frame); - } - pendingVirtualRowMeasureFramesRef.current.clear(); clearMouseScrollGesture(); clearUpwardInputEvidence(); lastTouchClientYRef.current = null; @@ -769,6 +684,42 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); + const reconcileReadingPosition = useStableEvent((userScrolled: boolean = false) => { + const container = scrollContainerRef.current; + const content = contentRef.current; + if (!isActiveRef.current || !container || !content) return; + const viewportTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const width = content.getBoundingClientRect().width; + const rows = Array.from( + content.querySelectorAll("[data-history-row-id]"), + (element) => { + const bounds = element.getBoundingClientRect(); + const id = element.dataset.historyRowId!; + measuredRowHeights.current.set(id, { width, height: bounds.height }); + return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; + }, + ); + if (followOutputRef.current || isJumpSettling()) { + readingAnchor.reset(); + return; + } + const correctedTop = readingAnchor.reconcile(container.scrollTop, rows, userScrolled); + if (Math.abs(correctedTop - container.scrollTop) > 0.5) { + container.scrollTop = correctedTop; + lastKnownScrollTopRef.current = container.scrollTop; + // The DOM scroll event arrives later. Fill the corrected virtual range in + // this layout so the reader does not get a frame containing only the pin. + virtualOffsetListener.current?.(container.scrollTop, false); + } + }); + + // Apply row positions and their scroll correction in the same paint, including + // the ordinary-to-virtualized handoff and measurements of newly mounted rows. + useLayoutEffect(() => { + reconcileReadingPosition(); + }); + const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); if (followOutputRef.current) { @@ -801,11 +752,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } lastKnownScrollTopRef.current = currentScrollTop; + reconcileReadingPosition(scrolledUp || scrolledDown); updateScrollMetrics(); evaluateHistoryStart(); }, [ evaluateHistoryStart, isJumpSettling, + reconcileReadingPosition, stopFollowingOutputFromUserIntent, updateScrollMetrics, ]); @@ -814,8 +767,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const initialHistoryStartState = createHistoryStartPaginationState(); historyStartPaginationStateRef.current = initialHistoryStartState; setHistoryStartPaginationState(initialHistoryStartState); - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; + readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -826,7 +778,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId]); + }, [evaluateHistoryStart, props.agentId, readingAnchor]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -959,9 +911,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } else { lastObservedViewportGeometryRef.current = nextGeometry; } - if (historyStartPrependAnchorActiveRef.current) { - applyHistoryStartPrependAnchor(); - } + reconcileReadingPosition(); if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartPrependSettle(); } @@ -980,7 +930,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool observer.disconnect(); }; }, [ - applyHistoryStartPrependAnchor, + reconcileReadingPosition, evaluateHistoryStart, isActive, reportReadingPosition, @@ -1188,9 +1138,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", + // The browser still anchors delayed image growth while following output. + // Detached reading has one owner: reconcileReadingPosition. + overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, }; - }, [isMobileBreakpoint, scrollEnabled]); + }, [followOutput, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index fc3d82b35c..197b7ae75f 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -12,7 +12,6 @@ import { findMountedWindowStart, getWebMountedRecentStreamItems, getWebPartialVirtualizationThreshold, - shouldAdjustScrollForVirtualRowResize, splitWebVirtualizedHistory, type IndexedStreamItem, } from "./web-virtualization"; @@ -170,55 +169,6 @@ describe("estimateStreamItemHeight", () => { }); }); -describe("virtual row resize anchoring", () => { - it("does not move the viewport when a visible row expands while detached", () => { - expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 1200, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(false); - }); - - it("keeps the reading position when a row above the viewport changes size", () => { - expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(true); - }); - - it("leaves history prepend and bottom following to their existing anchors", () => { - const baseInput = { - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }; - - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: true, - }), - ).toBe(false); - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: false, - remainingDistanceFromBottom: 64, - }), - ).toBe(false); - }); -}); - describe("web virtualization test overrides", () => { it("uses defaults unless explicit positive integer overrides are present", () => { const globalWithOverrides = globalThis as typeof globalThis & { diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index 13bc737e5f..5445783178 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -10,21 +10,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS; const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40; -export function shouldAdjustScrollForVirtualRowResize(input: { - isHistoryStartPrependActive: boolean; - rowStart: number; - scrollOffset: number; - remainingDistanceFromBottom: number; - bottomThreshold: number; -}): boolean { - if (input.isHistoryStartPrependActive) { - return false; - } - return ( - input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset - ); -} - type BottomAnchorE2ETestGlobals = typeof globalThis & { __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; }; diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 27afed7b9a..c9c0f07161 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -449,6 +449,18 @@ describe("MockLoadTestAgentClient", () => { unsubscribe(); }); + test("emits distinct configured responses across consecutive fixture turns", async () => { + const session = await new MockLoadTestAgentClient().createSession({ + provider: "mock", + cwd: process.cwd(), + model: "e2e-fast-stream", + featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, + }); + await session.run("Emit a synthetic turn failure."); + expect((await session.run("First prompt")).finalText).toBe("First response"); + expect((await session.run("Second prompt")).finalText).toBe("![Second](second.png)"); + }); + test("emits a settled assistant Markdown image path selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 2ebcc0b23e..fccc89224b 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -726,6 +726,12 @@ export class MockLoadTestAgentClient implements AgentClient { } } +function getConfiguredAssistantResponses(value: unknown): string[] { + return Array.isArray(value) && value.every((response) => typeof response === "string") + ? [...value] + : []; +} + export class MockLoadTestAgentSession implements AgentSession { readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; readonly capabilities = CAPABILITIES; @@ -739,6 +745,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly assistantResponse: string | null; + private readonly assistantResponses: string[]; private readonly streamingAssistantResponse: string | null; private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; @@ -754,6 +761,9 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockAssistantResponse === "string" ? options.config.featureValues.mockAssistantResponse : null; + this.assistantResponses = getConfiguredAssistantResponses( + options.config.featureValues?.mockAssistantResponses, + ); this.streamingAssistantResponse = typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" ? options.config.featureValues.mockStreamingAssistantResponse @@ -841,8 +851,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.scheduleSteeringReplayTurn(turn, steeringReplayShape); } else if (this.streamingAssistantResponse !== null) { this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); - } else if (this.assistantResponse !== null) { - this.scheduleSettledAssistantTurn(turn, this.assistantResponse); + } else if (this.assistantResponses.length > 0 || this.assistantResponse !== null) { + this.scheduleSettledAssistantTurn( + turn, + (this.assistantResponses.shift() ?? this.assistantResponse)!, + ); } else if (structuredBranchName) { this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); } else if (settledAssistantImageMarkdown) { From 5375f43a051c724d080e41efd73e84ccb6082ff5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:36:19 +0100 Subject: [PATCH 004/281] feat(plugins): expose agent closed lifecycle event (#5971) * feat(plugins): expose agent closed lifecycle event Emit the event when a live agent reaches its closed state so plugins can release agent-scoped resources. Keep archive and close notifications distinct. Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> * fix(plugins): finish close hooks during daemon shutdown Wait briefly for pending lifecycle events before stopping plugins so agent close hooks can release their resources during shutdown. Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> --- packages/plugin/src/server/lifecycle.ts | 1 + .../server/src/server/agent/agent-manager.ts | 5 ++ packages/server/src/server/bootstrap.ts | 5 ++ packages/server/src/server/plugins/index.ts | 5 ++ .../plugins/lifecycle-archive.e2e.test.ts | 57 ++++++++++++++++++- .../server/plugins/lifecycle.real.e2e.test.ts | 1 + .../src/server/plugins/lifecycle/index.ts | 1 + packages/server/src/server/plugins/runtime.ts | 25 +++++--- .../lifecycle-logger/index.server.ts | 4 ++ public-docs/plugins/reference.md | 3 + 10 files changed, 97 insertions(+), 10 deletions(-) diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 9115145cde..acebde32f1 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -60,6 +60,7 @@ export interface PluginLifecycleEvents { }; "agent.archived": { agent: PluginHookAgent; archivedAt: string }; "agent.created": { agent: PluginHookAgent }; + "agent.closed": { agent: PluginHookAgent }; "workspace.created": { workspace: PluginHookWorkspace }; "workspace.archived": { workspace: PluginHookWorkspace }; } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index aebede5fa1..7c39876b30 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3730,6 +3730,11 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); + if (!agent.internal) { + this.pluginLifecycle?.emit("agent.closed", { + agent: describeHookAgent({ ...agent, title: agent.config.title }), + }); + } } private subscribeToSession(agent: ActiveManagedAgent): void { if (agent.unsubscribeSession) { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 659ac54d02..ebf6e0bc81 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1807,6 +1807,11 @@ export async function createPaseoDaemon( wsServer?.prepareForShutdown(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); + await withTimeout({ + promise: pluginRuntime.drainEvents(), + timeoutMs: AGENT_CLOSE_TIMEOUT_MS, + label: "drain plugin lifecycle events", + }).catch((error) => logger.warn({ err: error }, "Plugin lifecycle events did not finish")); await agentManager.flushForShutdown().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 167216dbc1..3940ef033f 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -33,6 +33,7 @@ const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_ID interface PluginRuntimePort { emit?: PluginLifecycle["emit"]; + drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; @@ -119,6 +120,10 @@ export class PluginService { this.runtime.emit?.(name, event); }; + async drainEvents(): Promise { + await this.runtime.drainEvents?.(); + } + readonly before: PluginLifecycle["before"] = async (name, request) => { if (this.runtime.before) { return this.runtime.before(name, request); diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 8457c94711..6f3d8661e7 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,7 +6,7 @@ import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("workspace archive publishes agent archive hooks for both live and closed agents", async () => { +test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -33,7 +33,16 @@ test("workspace archive publishes agent archive hooks for both live and closed a workspaceId: workspace.id, }); await daemon.daemon.agentManager.closeAgent(closed.id); + await daemon.daemon.agentManager.closeAgent(closed.id); expect(daemon.daemon.agentManager.getAgent(closed.id)).toBeNull(); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id); + }) + .toEqual([closed.id]); await client.archiveWorkspace(workspace.id); await expect .poll(async () => { @@ -48,6 +57,50 @@ test("workspace archive publishes agent archive hooks for both live and closed a .sort(); }) .toEqual([live.id, closed.id].sort()); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id) + .sort(); + }) + .toEqual([live.id, closed.id].sort()); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const completedFile = path.join(directory, "closed.txt"); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { writeFile } from "node:fs/promises"; +import { setTimeout } from "node:timers/promises"; +export default function contribute(server) { + server.on("agent.closed", async (event, context) => { + await setTimeout(200, undefined, { signal: context.signal }); + await writeFile(${JSON.stringify(completedFile)}, event.agent.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ provider: "claude", cwd: directory }); + await daemon.close(); + expect(await readFile(completedFile, "utf8")).toBe(agent.id); } finally { await client.close(); await daemon.close(); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index bd71f0ce7e..fc9cfbd84d 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -280,6 +280,7 @@ test.skipIf(process.platform !== "linux")( }) .toEqual([ "agent.archived", + "agent.closed", "agent.created", "agent.permission_requested", "agent.permission_resolved", diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 766ccc0bce..447c325ad4 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -17,6 +17,7 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; export const lifecycleEventNames = [ "agent.created", + "agent.closed", "agent.turn_started", "agent.turn_ended", "agent.permission_requested", diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index a26e7cbfea..66d71d277d 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -295,6 +295,7 @@ async function resolveEntryPaths(directory: string): Promise<{ export class PluginRuntime { private readonly plugins = new Map(); + private readonly pendingEvents = new Set>(); private readonly logTails = new Map(); private readonly logger: pino.Logger; private readonly spawnChild: () => PluginChild; @@ -515,22 +516,30 @@ export class PluginRuntime { if (!loaded.hooks.events.includes(name)) { continue; } - void this.request(loaded, { + const request = this.request(loaded, { type: "hook", requestId: randomUUID(), kind: "event", name, input: event, - }).catch((error) => { - this.appendLog( - loaded.id, - "stderr", - `Lifecycle hook ${name} failed: ${describeError(error)}`, - ); - }); + }) + .then(() => undefined) + .catch((error) => { + this.appendLog( + loaded.id, + "stderr", + `Lifecycle hook ${name} failed: ${describeError(error)}`, + ); + }); + this.pendingEvents.add(request); + void request.finally(() => this.pendingEvents.delete(request)); } } + async drainEvents(): Promise { + await Promise.all(this.pendingEvents); + } + async before( name: Name, input: PluginBeforeRequests[Name], diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 04d967949c..0f5fd4ed95 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -29,6 +29,10 @@ export default function contribute(server: PluginServerContext) { logHook("agent.created", event); }); + server.on("agent.closed", (event) => { + logHook("agent.closed", event); + }); + server.on("agent.turn_started", (event) => { logHook("agent.turn_started", event); }); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 188e88c154..85e04bbdc3 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -654,6 +654,7 @@ plans, and mode changes; requesting permission does not end the turn. | Name | Event fields | Trigger | | ---------------------------- | ---------------------------------------- | -------------------------------------------------- | | `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume | +| `agent.closed` | `agent` | A live agent runtime closes | | `agent.turn_started` | `agent`, `turnId` | Live turn starts | | `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled | | `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending | @@ -663,6 +664,8 @@ plans, and mode changes; requesting permission does not end the turn. | `workspace.archived` | `workspace` | Archive state is saved | Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup; +closing an agent that is already closed does not emit another `agent.closed` event. +During daemon shutdown, pending event hooks have up to five seconds to finish before plugins stop. `workspace.created` is not a setup barrier before agent startup. **Shared payload shapes** (`@getpaseo/plugin/server`): From 7dc2a3568ce985845ecfdcc1dd6d118064de5841 Mon Sep 17 00:00:00 2001 From: Gustavo Ambrozio Date: Thu, 13 Aug 2026 07:22:44 -1000 Subject: [PATCH 005/281] fix(terminal): translate mac cmd/option editing shortcuts xterm ignores meta+arrows, and 6.x dropped the macOS conversion of option+arrow to ESC b/f, so cmd+arrow did nothing and option+arrow leaked ESC[1;3C-style sequences the shell rendered as a stray "C"/"D" plus a bell. Map the chords the way Terminal.app and iTerm2 (Natural Text Editing) do: cmd+left/right -> ^A/^E, option+left/right -> ESC b/f, cmd+backspace -> ^U. Co-Authored-By: Claude Fable 5 --- .../terminal-emulator-runtime.browser.test.ts | 125 ++++++++++++++++-- .../runtime/terminal-emulator-runtime.ts | 45 ++++++- .../webview/terminal-emulator-webview-html.ts | 2 +- packages/app/src/utils/terminal-keys.test.ts | 43 ++++++ packages/app/src/utils/terminal-keys.ts | 50 +++++++ 5 files changed, 252 insertions(+), 13 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 10a2b0b632..ccb88bac7e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -45,6 +45,11 @@ interface MountedTerminal { const mountedTerminals: MountedTerminal[] = []; +const MAC_DESKTOP_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; +const LINUX_DESKTOP_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + function nextFrame(): Promise { return new Promise((resolve) => { requestAnimationFrame(() => { @@ -179,6 +184,7 @@ function getBrowserTerminal(): BrowserTerminal { function dispatchTerminalKey(input: { host: HTMLElement; key: string; + keyCode?: number; shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; @@ -189,17 +195,50 @@ function dispatchTerminalKey(input: { throw new Error("Expected xterm textarea to be mounted"); } textarea.focus(); - return textarea.dispatchEvent( - new KeyboardEvent("keydown", { - key: input.key, - shiftKey: input.shiftKey ?? false, - ctrlKey: input.ctrlKey ?? false, - altKey: input.altKey ?? false, - metaKey: input.metaKey ?? false, - bubbles: true, - cancelable: true, - }), - ); + const event = new KeyboardEvent("keydown", { + key: input.key, + shiftKey: input.shiftKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + metaKey: input.metaKey ?? false, + bubbles: true, + cancelable: true, + }); + if (input.keyCode !== undefined) { + // Synthetic KeyboardEvents always report keyCode 0; xterm's fallback encoder + // switches on keyCode, so hardware-like events need it filled in. + Object.defineProperty(event, "keyCode", { value: input.keyCode }); + } + return textarea.dispatchEvent(event); +} + +// The runtime reads the platform per keydown, so shadowing the instance properties +// makes mac-only key handling testable from any CI host. +function withNavigatorPlatform( + input: { userAgent: string; platform: string }, + run: () => void, +): void { + const restores = (["userAgent", "platform"] as const).map((property) => { + const original = Object.getOwnPropertyDescriptor(navigator, property); + Object.defineProperty(navigator, property, { + value: input[property], + configurable: true, + }); + return () => { + if (original) { + Object.defineProperty(navigator, property, original); + } else { + Reflect.deleteProperty(navigator, property); + } + }; + }); + try { + run(); + } finally { + for (const restore of restores) { + restore(); + } + } } /** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ @@ -514,6 +553,70 @@ describe("terminal emulator runtime in a real browser", () => { }); }); + it("translates mac editing shortcuts into shell editing sequences", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => { + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true }); + }); + await nextFrame(); + + expect(mounted.inputs).toEqual([ + "\x1b[C", // plain arrow keeps xterm's default encoding + "\x01", // cmd+left -> line start + "\x05", // cmd+right -> line end + "\x1bb", // option+left -> word back + "\x1bf", // option+right -> word forward + "\x15", // cmd+backspace -> kill line + ]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it("keeps cmd+shift+arrow free for app-level shortcuts", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => { + dispatchTerminalKey({ + host: mounted.host, + key: "ArrowRight", + keyCode: 39, + metaKey: true, + shiftKey: true, + }); + }); + await nextFrame(); + + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it("does not remap modified arrows on non-mac platforms", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + withNavigatorPlatform({ userAgent: LINUX_DESKTOP_UA, platform: "Linux x86_64" }, () => { + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + }); + await nextFrame(); + + // Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding. + expect(mounted.inputs).toEqual(["\x1b[1;3C"]); + }); + it.each([ { name: "DA1", bytes: "\x1b[c" }, { name: "DA1-zero", bytes: "\x1b[0c" }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 4b7afb74c2..28d3158347 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,11 +15,14 @@ import { } from "@getpaseo/protocol/terminal-input-mode"; import { type PendingTerminalModifiers, + hasPendingTerminalModifiers, isAppleHandheldPlatform, + isMacLikePlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; @@ -142,6 +145,17 @@ declare global { } } +// Read at call time, not module load: keydown handling consults it per event. +function detectMacLikePlatform(): boolean { + return ( + typeof navigator !== "undefined" && + isMacLikePlatform({ + userAgent: navigator.userAgent, + platform: (navigator as Navigator & { platform?: string }).platform, + }) + ); +} + const isAppleHandheld = typeof navigator !== "undefined" && isAppleHandheldPlatform({ @@ -299,7 +313,12 @@ export class TerminalEmulatorRuntime { attachKeyEventHandler( terminal: Pick< Terminal, - "attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste" + | "attachCustomKeyEventHandler" + | "hasSelection" + | "getSelection" + | "paste" + | "input" + | "scrollToBottom" >, ): void { terminal.attachCustomKeyEventHandler((event) => { @@ -339,6 +358,10 @@ export class TerminalEmulatorRuntime { return true; } + if (this.maybeSendMacEditingShortcut(terminal, event)) { + return false; + } + const normalizedKey = normalizeDomTerminalKey(event.key); if (!normalizedKey || isTerminalModifierDomKey(event.key)) { return true; @@ -426,6 +449,26 @@ export class TerminalEmulatorRuntime { return this.inputModeTracker.getState(); } + private maybeSendMacEditingShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + if (!detectMacLikePlatform() || hasPendingTerminalModifiers(this.pendingModifiers)) { + return false; + } + const editingShortcutData = resolveMacTerminalEditingShortcut(event); + if (editingShortcutData === null) { + return false; + } + // Routed through terminal.input() so the data takes the same path as typed + // keys: onData -> callbacks.onInput, selection clearing included. + terminal.input(editingShortcutData, true); + terminal.scrollToBottom(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + mount(input: TerminalEmulatorRuntimeMountInput): void { this.unmount(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index efa77dda73..1d23b244b5 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 5d39d55cfb..de88638b06 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -7,6 +7,7 @@ import { mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, resolvePendingModifierDataInput, shouldInterceptDomTerminalKey, } from "./terminal-keys"; @@ -351,3 +352,45 @@ describe("terminal key helpers", () => { }); }); }); + +describe("resolveMacTerminalEditingShortcut", () => { + function shortcut( + key: string, + modifiers: Partial>, + ) { + return resolveMacTerminalEditingShortcut({ + key, + ctrlKey: modifiers.ctrl ?? false, + shiftKey: modifiers.shift ?? false, + altKey: modifiers.alt ?? false, + metaKey: modifiers.meta ?? false, + }); + } + + it("maps cmd+arrow to line start/end", () => { + expect(shortcut("ArrowLeft", { meta: true })).toBe("\x01"); + expect(shortcut("ArrowRight", { meta: true })).toBe("\x05"); + }); + + it("maps option+arrow to word movement", () => { + expect(shortcut("ArrowLeft", { alt: true })).toBe("\x1bb"); + expect(shortcut("ArrowRight", { alt: true })).toBe("\x1bf"); + }); + + it("maps cmd+backspace to kill-line", () => { + expect(shortcut("Backspace", { meta: true })).toBe("\x15"); + }); + + it("leaves other combinations to xterm", () => { + expect(shortcut("ArrowLeft", {})).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, shift: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, ctrl: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, alt: true })).toBeNull(); + expect(shortcut("ArrowLeft", { alt: true, shift: true })).toBeNull(); + expect(shortcut("ArrowUp", { meta: true })).toBeNull(); + expect(shortcut("ArrowDown", { alt: true })).toBeNull(); + // Option+Backspace already works natively in xterm (ESC DEL). + expect(shortcut("Backspace", { alt: true })).toBeNull(); + expect(shortcut("k", { meta: true })).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index a25d4e08a7..eae4fae982 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -88,6 +88,15 @@ interface AppleHandheldDetectionInput { maxTouchPoints: number | null | undefined; } +// Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both +// follow the same cmd/option editing conventions on hardware keyboards. +export function isMacLikePlatform(input: { + userAgent: string | null | undefined; + platform: string | null | undefined; +}): boolean { + return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? ""); +} + // iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish // iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS. export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean { @@ -135,6 +144,47 @@ export function shouldInterceptDomTerminalKey(args: { return false; } +// macOS terminals translate these editing chords instead of forwarding raw modified +// keys: Terminal.app and iTerm2 ("Natural Text Editing") send ^A/^E for cmd+arrows, +// ESC b / ESC f for option+arrows, and ^U for cmd+backspace. xterm ignores meta+arrow +// entirely, and 6.x dropped the 5.x macOS conversion of option+arrow to ESC b/f, so +// without this the shell receives nothing or an `ESC[1;3C`-style sequence it can't +// parse (which zsh renders as a stray "C"/"D" plus a bell). +export function resolveMacTerminalEditingShortcut(args: { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +}): string | null { + if (args.ctrlKey || args.shiftKey) { + return null; + } + if (args.metaKey && !args.altKey) { + switch (args.key) { + case "ArrowLeft": + return "\x01"; + case "ArrowRight": + return "\x05"; + case "Backspace": + return "\x15"; + default: + return null; + } + } + if (args.altKey && !args.metaKey) { + switch (args.key) { + case "ArrowLeft": + return "\x1bb"; + case "ArrowRight": + return "\x1bf"; + default: + return null; + } + } + return null; +} + export function mergeTerminalModifiers(args: { pendingModifiers: PendingTerminalModifiers; ctrlKey: boolean; From a983ae0444b92a1870419eecb96dd42fd00773d4 Mon Sep 17 00:00:00 2001 From: Gustavo Ambrozio Date: Thu, 13 Aug 2026 10:36:39 -1000 Subject: [PATCH 006/281] refactor(terminal): inject mac platform flag at mount Greptile review: the browser tests patched navigator.userAgent/platform globals to exercise mac-only key handling. Make the platform an explicit mount option (defaulting to navigator detection) so tests inject it directly and the runtime stops sniffing navigator per keydown. Co-Authored-By: Claude Fable 5 --- .../terminal-emulator-runtime.browser.test.ts | 76 +++++-------------- .../runtime/terminal-emulator-runtime.ts | 6 +- .../webview/terminal-emulator-webview-html.ts | 2 +- 3 files changed, 24 insertions(+), 60 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index ccb88bac7e..176f6bed2d 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -45,11 +45,6 @@ interface MountedTerminal { const mountedTerminals: MountedTerminal[] = []; -const MAC_DESKTOP_UA = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; -const LINUX_DESKTOP_UA = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; - function nextFrame(): Promise { return new Promise((resolve) => { requestAnimationFrame(() => { @@ -82,6 +77,7 @@ function createTerminalHost(input: { width: number; height: number; scrollback?: number; + isMacLikePlatform?: boolean; }): MountedTerminal { const root = document.createElement("div"); root.style.width = `${input.width}px`; @@ -127,6 +123,7 @@ function createTerminalHost(input: { host, initialSnapshot: null, scrollback: input.scrollback ?? 10_000, + isMacLikePlatform: input.isMacLikePlatform, theme: { background: "#0b0b0b", foreground: "#e6e6e6", @@ -212,35 +209,6 @@ function dispatchTerminalKey(input: { return textarea.dispatchEvent(event); } -// The runtime reads the platform per keydown, so shadowing the instance properties -// makes mac-only key handling testable from any CI host. -function withNavigatorPlatform( - input: { userAgent: string; platform: string }, - run: () => void, -): void { - const restores = (["userAgent", "platform"] as const).map((property) => { - const original = Object.getOwnPropertyDescriptor(navigator, property); - Object.defineProperty(navigator, property, { - value: input[property], - configurable: true, - }); - return () => { - if (original) { - Object.defineProperty(navigator, property, original); - } else { - Reflect.deleteProperty(navigator, property); - } - }; - }); - try { - run(); - } finally { - for (const restore of restores) { - restore(); - } - } -} - /** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ function hyperlink(input: { url: string; text: string }): string { return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`; @@ -555,18 +523,16 @@ describe("terminal emulator runtime in a real browser", () => { it("translates mac editing shortcuts into shell editing sequences", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360 }); + const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); - withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => { - dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 }); - dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true }); - dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); - dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); - dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); - dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true }); - }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true }); await nextFrame(); expect(mounted.inputs).toEqual([ @@ -582,18 +548,16 @@ describe("terminal emulator runtime in a real browser", () => { it("keeps cmd+shift+arrow free for app-level shortcuts", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360 }); + const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); - withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => { - dispatchTerminalKey({ - host: mounted.host, - key: "ArrowRight", - keyCode: 39, - metaKey: true, - shiftKey: true, - }); + dispatchTerminalKey({ + host: mounted.host, + key: "ArrowRight", + keyCode: 39, + metaKey: true, + shiftKey: true, }); await nextFrame(); @@ -603,14 +567,12 @@ describe("terminal emulator runtime in a real browser", () => { it("does not remap modified arrows on non-mac platforms", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360 }); + const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: false }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); - withNavigatorPlatform({ userAgent: LINUX_DESKTOP_UA, platform: "Linux x86_64" }, () => { - dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); - dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); - }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); await nextFrame(); // Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding. diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 28d3158347..49c667e7eb 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -60,6 +60,7 @@ export interface TerminalEmulatorRuntimeMountInput { theme: ITheme; fontFamily?: string; fontSize?: number; + isMacLikePlatform?: boolean; } export interface TerminalEmulatorRuntimeCallbacks { @@ -145,7 +146,6 @@ declare global { } } -// Read at call time, not module load: keydown handling consults it per event. function detectMacLikePlatform(): boolean { return ( typeof navigator !== "undefined" && @@ -262,6 +262,7 @@ export class TerminalEmulatorRuntime { private hasUngatedWrites = false; private readonly inputModeDecoder = new TextDecoder(); private suppressInput = false; + private isMacLikePlatform = false; private readonly inputModeTracker = new TerminalInputModeTracker(); private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState(); private themeBackgroundElements: HTMLElement[] = []; @@ -453,7 +454,7 @@ export class TerminalEmulatorRuntime { terminal: Pick, event: KeyboardEvent, ): boolean { - if (!detectMacLikePlatform() || hasPendingTerminalModifiers(this.pendingModifiers)) { + if (!this.isMacLikePlatform || hasPendingTerminalModifiers(this.pendingModifiers)) { return false; } const editingShortcutData = resolveMacTerminalEditingShortcut(event); @@ -474,6 +475,7 @@ export class TerminalEmulatorRuntime { input.host.innerHTML = ""; this.lastSize = null; + this.isMacLikePlatform = input.isMacLikePlatform ?? detectMacLikePlatform(); this.inputModeTracker.reset(); this.emitInputModeChange(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index 1d23b244b5..bddf068153 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; From 70210f1071974c7cf9c2ed55c5598ebd8456c824 Mon Sep 17 00:00:00 2001 From: marvin-ambrozio Date: Thu, 10 Sep 2026 08:19:41 -1000 Subject: [PATCH 007/281] refactor(app): name the terminal key-event input shapes Promote the inline multi-property parameter types added by this PR to named interfaces, per docs/coding-standards.md. Co-Authored-By: Claude Opus 5 --- .../terminal-emulator-runtime.browser.test.ts | 12 +++++--- packages/app/src/utils/terminal-keys.ts | 28 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 176f6bed2d..020caa23c7 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -73,12 +73,14 @@ function settleMountRefits(): Promise { return new Promise((resolve) => setTimeout(resolve, 2_600)); } -function createTerminalHost(input: { +interface CreateTerminalHostInput { width: number; height: number; scrollback?: number; isMacLikePlatform?: boolean; -}): MountedTerminal { +} + +function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const root = document.createElement("div"); root.style.width = `${input.width}px`; root.style.height = `${input.height}px`; @@ -178,7 +180,7 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } -function dispatchTerminalKey(input: { +interface DispatchTerminalKeyInput { host: HTMLElement; key: string; keyCode?: number; @@ -186,7 +188,9 @@ function dispatchTerminalKey(input: { ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; -}): boolean { +} + +function dispatchTerminalKey(input: DispatchTerminalKeyInput): boolean { const textarea = input.host.querySelector("textarea"); if (!textarea) { throw new Error("Expected xterm textarea to be mounted"); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index eae4fae982..79d4f4820b 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -82,18 +82,18 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers) return modifiers.ctrl || modifiers.shift || modifiers.alt; } -interface AppleHandheldDetectionInput { +interface MacPlatformDetectionInput { userAgent: string | null | undefined; platform: string | null | undefined; +} + +interface AppleHandheldDetectionInput extends MacPlatformDetectionInput { maxTouchPoints: number | null | undefined; } // Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both // follow the same cmd/option editing conventions on hardware keyboards. -export function isMacLikePlatform(input: { - userAgent: string | null | undefined; - platform: string | null | undefined; -}): boolean { +export function isMacLikePlatform(input: MacPlatformDetectionInput): boolean { return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? ""); } @@ -144,19 +144,23 @@ export function shouldInterceptDomTerminalKey(args: { return false; } +interface MacTerminalEditingShortcutInput { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +} + // macOS terminals translate these editing chords instead of forwarding raw modified // keys: Terminal.app and iTerm2 ("Natural Text Editing") send ^A/^E for cmd+arrows, // ESC b / ESC f for option+arrows, and ^U for cmd+backspace. xterm ignores meta+arrow // entirely, and 6.x dropped the 5.x macOS conversion of option+arrow to ESC b/f, so // without this the shell receives nothing or an `ESC[1;3C`-style sequence it can't // parse (which zsh renders as a stray "C"/"D" plus a bell). -export function resolveMacTerminalEditingShortcut(args: { - key: string; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; - metaKey: boolean; -}): string | null { +export function resolveMacTerminalEditingShortcut( + args: MacTerminalEditingShortcutInput, +): string | null { if (args.ctrlKey || args.shiftKey) { return null; } From e18f9e8f4361b6cfa3a0ea831d70bcd1dd9bdb0b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 13:15:29 +0200 Subject: [PATCH 008/281] refactor(terminal): share platform policy across keyboard shortcuts Use the existing platform input for editing, Find, and clipboard behavior. Exercise Mac and Linux modifier chords through the app and a real PTY, and keep pending modifier chips authoritative. --- .../app/e2e/browser/terminal-find.spec.ts | 85 +++++++++++++++++++ .../terminal-emulator-runtime.browser.test.ts | 27 ++++-- .../runtime/terminal-emulator-runtime.test.ts | 2 + .../runtime/terminal-emulator-runtime.ts | 18 +--- .../webview/terminal-emulator-webview-html.ts | 2 +- packages/app/src/utils/terminal-keys.ts | 11 +-- 6 files changed, 112 insertions(+), 33 deletions(-) diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 00f80ff4cb..56159b49c4 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -199,6 +199,49 @@ async function quitVim(page: Page) { }); } +interface TerminalChord { + key: string; + bytes: string; +} + +async function openKeyboardCapture(page: Page) { + await test.step("Open a real terminal process that reports received bytes", async () => { + const terminal = await harness.createTerminal({ + name: "Keyboard bytes", + command: process.execPath, + args: [ + "-e", + ` + process.stdin.setRawMode(true); + let received = Buffer.alloc(0); + process.stdin.on('data', chunk => { + received = Buffer.concat([received, chunk]); + process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n'); + }); + console.log('KEYBOARD_READY'); + `, + ], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY"); + }); +} + +async function expectTerminalChords(page: Page, chords: TerminalChord[]) { + let received = ""; + for (const chord of chords) { + await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => { + await pressTerminalShortcut(page, chord.key); + // A printable sentinel proves delivery even for chords expected to send nothing. + await input(page).pressSequentially("x"); + received += Buffer.from(chord.bytes + "x").toString("hex"); + await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`); + }); + } + await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page)); + await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") }); +} + let harness: TerminalE2EHarness; test.beforeEach(async () => { harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" }); @@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", }); + test("encodes editing chords without confusing Control and Command", async ({ page }) => { + await openKeyboardCapture(page); + await expectTerminalChords(page, [ + { key: "Meta+ArrowLeft", bytes: "\x01" }, + { key: "Meta+ArrowRight", bytes: "\x05" }, + { key: "Alt+ArrowLeft", bytes: "\x1bb" }, + { key: "Alt+ArrowRight", bytes: "\x1bf" }, + { key: "Meta+Backspace", bytes: "\x15" }, + { key: "Control+c", bytes: "\x03" }, + { key: "Control+a", bytes: "\x01" }, + { key: "Control+e", bytes: "\x05" }, + { key: "Control+v", bytes: "\x16" }, + { key: "Control+f", bytes: "\x06" }, + { key: "ArrowLeft", bytes: "\x1b[D" }, + { key: "ArrowRight", bytes: "\x1b[C" }, + { key: "Alt+Backspace", bytes: "\x1b\x7f" }, + { key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" }, + ]); + await pressTerminalShortcut(page, "Meta+f"); + await expect(query(page)).toBeFocused(); + }); + test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => { await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); @@ -232,6 +297,26 @@ test.describe("macOS terminal shortcuts", () => { }); }); +test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => { + await openKeyboardCapture(page); + await expectTerminalChords(page, [ + { key: "Meta+ArrowLeft", bytes: "" }, + { key: "Meta+ArrowRight", bytes: "" }, + { key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" }, + { key: "Alt+ArrowRight", bytes: "\x1b[1;3C" }, + { key: "Meta+Backspace", bytes: "\x7f" }, + { key: "Control+c", bytes: "\x03" }, + { key: "Control+a", bytes: "\x01" }, + { key: "Control+e", bytes: "\x05" }, + { key: "Control+ArrowLeft", bytes: "\x1b[1;5D" }, + { key: "Control+ArrowRight", bytes: "\x1b[1;5C" }, + { key: "Control+Backspace", bytes: "\x08" }, + { key: "Alt+Backspace", bytes: "\x1b\x7f" }, + ]); + await pressTerminalShortcut(page, "Control+f"); + await expect(query(page)).toBeFocused(); +}); + test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => { await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 020caa23c7..b173545abc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -77,7 +77,7 @@ interface CreateTerminalHostInput { width: number; height: number; scrollback?: number; - isMacLikePlatform?: boolean; + isMac?: boolean; } function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { @@ -100,7 +100,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; const openedUrls: string[] = []; - const runtime = new TerminalEmulatorRuntime(); + const runtime = new TerminalEmulatorRuntime( + input.isMac === undefined ? undefined : { isMac: input.isMac }, + ); runtime.setCallbacks({ callbacks: { onInput: (data) => { @@ -125,7 +127,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { host, initialSnapshot: null, scrollback: input.scrollback ?? 10_000, - isMacLikePlatform: input.isMacLikePlatform, theme: { background: "#0b0b0b", foreground: "#e6e6e6", @@ -527,7 +528,7 @@ describe("terminal emulator runtime in a real browser", () => { it("translates mac editing shortcuts into shell editing sequences", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true }); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); @@ -550,9 +551,23 @@ describe("terminal emulator runtime in a real browser", () => { expect(mounted.terminalKeys).toEqual([]); }); + it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.runtime.setPendingModifiers({ + pendingModifiers: { ctrl: true, alt: false, shift: false }, + }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + await nextFrame(); + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([ + { key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false }, + ]); + }); + it("keeps cmd+shift+arrow free for app-level shortcuts", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true }); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); @@ -571,7 +586,7 @@ describe("terminal emulator runtime in a real browser", () => { it("does not remap modified arrows on non-mac platforms", async () => { await page.viewport(900, 600); - const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: false }); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 0108815add..a11cedbfe2 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => { hasSelection: () => false, getSelection: () => "", paste: () => {}, + input: () => {}, + scrollToBottom: () => {}, }); expect(passedToXterm).toBe(!opensFind); expect(findRequests).toBe(opensFind ? 1 : 0); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 49c667e7eb..cc4462ff7b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -17,7 +17,6 @@ import { type PendingTerminalModifiers, hasPendingTerminalModifiers, isAppleHandheldPlatform, - isMacLikePlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, @@ -60,7 +59,6 @@ export interface TerminalEmulatorRuntimeMountInput { theme: ITheme; fontFamily?: string; fontSize?: number; - isMacLikePlatform?: boolean; } export interface TerminalEmulatorRuntimeCallbacks { @@ -146,16 +144,6 @@ declare global { } } -function detectMacLikePlatform(): boolean { - return ( - typeof navigator !== "undefined" && - isMacLikePlatform({ - userAgent: navigator.userAgent, - platform: (navigator as Navigator & { platform?: string }).platform, - }) - ); -} - const isAppleHandheld = typeof navigator !== "undefined" && isAppleHandheldPlatform({ @@ -262,7 +250,6 @@ export class TerminalEmulatorRuntime { private hasUngatedWrites = false; private readonly inputModeDecoder = new TextDecoder(); private suppressInput = false; - private isMacLikePlatform = false; private readonly inputModeTracker = new TerminalInputModeTracker(); private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState(); private themeBackgroundElements: HTMLElement[] = []; @@ -395,7 +382,7 @@ export class TerminalEmulatorRuntime { ...modifiers, }); - if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { + if (hasPendingTerminalModifiers(this.pendingModifiers)) { this.callbacks.onPendingModifiersConsumed?.(); } @@ -454,7 +441,7 @@ export class TerminalEmulatorRuntime { terminal: Pick, event: KeyboardEvent, ): boolean { - if (!this.isMacLikePlatform || hasPendingTerminalModifiers(this.pendingModifiers)) { + if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) { return false; } const editingShortcutData = resolveMacTerminalEditingShortcut(event); @@ -475,7 +462,6 @@ export class TerminalEmulatorRuntime { input.host.innerHTML = ""; this.lastSize = null; - this.isMacLikePlatform = input.isMacLikePlatform ?? detectMacLikePlatform(); this.inputModeTracker.reset(); this.emitInputModeChange(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index bddf068153..c7fc27652e 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 79d4f4820b..09ba53c182 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -82,21 +82,12 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers) return modifiers.ctrl || modifiers.shift || modifiers.alt; } -interface MacPlatformDetectionInput { +interface AppleHandheldDetectionInput { userAgent: string | null | undefined; platform: string | null | undefined; -} - -interface AppleHandheldDetectionInput extends MacPlatformDetectionInput { maxTouchPoints: number | null | undefined; } -// Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both -// follow the same cmd/option editing conventions on hardware keyboards. -export function isMacLikePlatform(input: MacPlatformDetectionInput): boolean { - return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? ""); -} - // iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish // iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS. export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean { From 5c1be59b68cf48545c93326cbf6137e1289961ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 13:27:50 +0200 Subject: [PATCH 009/281] test(terminal): isolate Linux keyboard policy from the host platform --- packages/app/e2e/browser/terminal-find.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 56159b49c4..91e8e5826a 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -297,7 +297,19 @@ test.describe("macOS terminal shortcuts", () => { }); }); +async function useLinuxKeyboardPlatform(page: Page) { + // A user agent alone leaves navigator.platform as MacIntel on macOS hosts. + // Emulate both browser identity fields before the app reads its keyboard policy. + const session = await page.context().newCDPSession(page); + await session.send("Emulation.setUserAgentOverride", { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + platform: "Linux x86_64", + }); +} + test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => { + await useLinuxKeyboardPlatform(page); await openKeyboardCapture(page); await expectTerminalChords(page, [ { key: "Meta+ArrowLeft", bytes: "" }, @@ -318,6 +330,7 @@ test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }); test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => { + await useLinuxKeyboardPlatform(page); await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); await expect(query(page)).toBeFocused(); From 42e221746c94a719d3541a9c87cf8bffa57e1c02 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 13:55:11 +0100 Subject: [PATCH 010/281] fix(app): render linked pictures in PR markdown (#5968) * fix(app): render linked pictures in PR markdown Consume image-only headings, anchors, and picture wrappers together so splitting at the fallback image does not leave raw opening tags or discard its link. * fix(app): preserve picture block boundaries in markdown * fix(app): parse nested image wrappers without recursion * test(app): account for delayed wheel input in scroll assertions CI recorded a 480px wheel scroll delivered after its event alongside 41px of image growth below the new reader. Use the same input budget to locate the reader and account for intervening growth, while continuing to reject added viewport displacement. * test(app): locate the scroll reader from frame movement --- .../agent-timeline-scroll-smoothness.spec.ts | 66 ++++++++++++++++ .../helpers/timeline-scroll-smoothness.ts | 32 ++++---- .../src/components/markdown/html-ish.test.ts | 79 +++++++++++++++++++ .../app/src/components/markdown/html-ish.ts | 75 +++++++++++++++--- 4 files changed, 226 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index b45007fa0b..ee4201ea75 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -34,6 +34,7 @@ test("scroll detector distinguishes entered image growth from a simultaneous vie const after: ScrollFrame = { ...before, at: 1400, + scrollTop: 900, wheelTotal: 100, lastWheelAt: 1050, rows: [ @@ -48,6 +49,7 @@ test("scroll detector distinguishes entered image growth from a simultaneous vie { ...after, wheelTotal: 1000, + scrollTop: 0, rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), }, ]), @@ -66,6 +68,70 @@ test("scroll detector distinguishes entered image growth from a simultaneous vie ).toHaveLength(1); }); +test("scroll detector accounts for delayed wheel input and growth below the new reader", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "new-reader", top: -520, height: 278 }, + { id: "image", top: -242, height: 184 }, + { id: "old-reader", top: -58, height: 130 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 }; + const after: ScrollFrame = { + ...wheel, + at: 1220, + scrollTop: 1520, + scrollHeight: 5041, + anchor: "new-reader", + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 225 }, + { id: "old-reader", top: 463, height: 130 }, + ], + }; + expect(findScrollJumps([before, wheel, after])).toEqual([]); + // The previous 480 px input has already moved the viewport; the recent + // budget includes it, but only the next 480 px belongs to this frame. + const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 }; + const steadyAfter = { + ...after, + at: 1100, + wheelTotal: 960, + lastWheelAt: 1099, + scrollHeight: 6000, + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 1184 }, + { id: "old-reader", top: 1422, height: 130 }, + ], + }; + expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]); + + expect( + findScrollJumps([ + before, + wheel, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); +}); + for (const cadence of scrollCadences) { test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ page, diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index 63ef1c10a0..f550b02fdc 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -278,12 +278,13 @@ export function findScrollJumps(frames: ScrollFrame[]) { if (!previous?.anchor) return []; // Wheel input can move the reading line onto an image before it expands. // Follow that intended row, not text now below the image. - const inputDelta = current.wheelTotal - previous.wheelTotal; - const availableScroll = Math.min(inputDelta, previous.scrollTop); + const recent = frames.findLast((frame) => frame.at <= previous.at - 100); + const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); + const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop); + const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop); const readingLine = 8 - availableScroll; const currentRows = new Map(current.rows.map((row) => [row.id, row])); const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine); - const intendedHeight = intendedRow && currentRows.get(intendedRow.id)?.height; const anchor = previous.anchor; const before = currentRows.has(anchor) ? previous.rows.find((row) => row.id === anchor)! @@ -298,18 +299,21 @@ export function findScrollJumps(frames: ScrollFrame[]) { // A busy main thread can deliver wheel movement hundreds of milliseconds // after its event. Only assert idle stability after the driver stops input. const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; - const recent = frames.findLast((frame) => frame.at <= previous.at - 100); - const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); - // Growth displaces only later rows. It never grants extra movement to the - // image itself, and its top must still follow the input budget. - const enteredRowGrowth = - inputDelta > 0 && + // Wheel events can precede their scroll update, or include input already + // applied in earlier frames. Locate the reader with this frame's scroll, + // bounded by recent input. Growth below it can move the old anchor without + // moving the reading line. + const readerFollowsInput = intendedRow && - intendedHeight !== undefined && - before.top >= intendedRow.top + intendedRow.height && - Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32 - ? Math.max(0, intendedHeight - intendedRow.height) - : 0; + currentRows.has(intendedRow.id) && + Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32; + const enteredRowGrowth = readerFollowsInput + ? previous.rows.reduce((growth, row) => { + if (row.top < intendedRow.top || row.top >= before.top) return growth; + const height = currentRows.get(row.id)?.height ?? row.height; + return growth + Math.max(0, height - row.height); + }, 0) + : 0; const excessForward = movement > wheelBudget + enteredRowGrowth + 32; // Upward wheel input moves the same text DOWN the viewport. A negative move // is a reversal, independent of legitimate scrollTop compensation on prepend. diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts index 37e3aeddb5..394ea47ed8 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -113,6 +113,85 @@ describe("splitHtmlishMarkdown", () => { ]); }); + it("renders a linked picture inside a heading without exposing its HTML", () => { + const source = [ + '

', + '', + '', + 'Retrigger', + "

", + "", + "Confidence Score: 5/5", + "", + "
SummaryNo outstanding findings.
", + ].join("\n"); + + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + alt: "Retrigger", + src: "https://example.com/default.svg", + href: "https://example.com/retrigger?id=73851569", + width: 85, + height: 24, + }, + { kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" }, + { kind: "details", summary: "Summary", body: "No outstanding findings." }, + ]); + }); + + it.each([ + ["", ""], + ["

", "

"], + ['', ""], + ])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => { + const picture = + 'Image'; + + expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([ + { kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" }, + ]); + }); + + it("keeps picture examples literal inside inline and fenced code", () => { + const picture = + ''; + const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``; + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => { + const source = `<${wrapper}>Image`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + src: "https://example.com/default.svg", + alt: "Image", + href: "https://example.com/page", + }, + ]); + }); + + it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => { + const source = `<${wrapper}>ImageNext section`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" }, + { kind: "markdown", text: "Next section" }, + ]); + }); + + it("handles deeply nested image wrappers without exhausting the stack", () => { + const source = `${"
".repeat(10000)}Image${"
".repeat(10000)}`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + src: "https://example.com/image.svg", + alt: "Image", + href: "https://example.com/page", + }, + ]); + }); + it("preserves inline image parts inside details bodies", () => { expect( splitHtmlishMarkdown( diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts index 93a88cd4b8..346a0dfc13 100644 --- a/packages/app/src/components/markdown/html-ish.ts +++ b/packages/app/src/components/markdown/html-ish.ts @@ -29,7 +29,7 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm; const BACKTICK_RUN_RE = /`+/g; const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i; const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i; -const VOID_HTML_TAGS = new Set(["br", "img"]); +const VOID_HTML_TAGS = new Set(["br", "img", "source"]); const MARKDOWN_TAG_WRAPPERS: Readonly> = { b: ["**", "**"], del: ["~~", "~~"], @@ -140,25 +140,73 @@ function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImagePars return image ? { part: image, end: start + 1 } : null; } - if (token.name !== "a") { + if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) { return null; } - const closeIndex = findMatchingClose(tokens, start, "a"); - if (closeIndex === null) { - return null; - } + return parseWrappedImageAt(tokens, start); +} - const image = getSingleImageChild(tokens.slice(start + 1, closeIndex)); - if (!image) { - return null; - } +function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null { + // Walk an image-only wrapper chain once. Recursing over copied child slices + // makes deeply nested review HTML quadratic and can exhaust the JS stack. + const wrappers: string[] = []; + let image: MarkdownInlineImagePart | null = null; + let href: string | undefined; + let hasLink = false; - const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href)); - return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null; + for (let index = start; index < tokens.length; index += 1) { + const child = tokens[index]; + if (child.kind === "comment" || isWhitespaceText(child)) { + continue; + } + if (child.kind !== "tag") { + return null; + } + if (child.closing) { + if (!image || wrappers.pop() !== child.name) { + return null; + } + if (wrappers.length === 0) { + return { part: image, end: index + 1 }; + } + continue; + } + // Pictures use their img fallback, not responsive source selection. + if (child.name === "source" && wrappers.at(-1) === "picture") { + continue; + } + if (image) { + return null; + } + if (child.name === "img") { + image = imageTokenToInlineImage(child, href); + if (!image) { + return null; + } + continue; + } + if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) { + return null; + } + if (child.name === "a" && !hasLink) { + href = safeHref(child.attributes.href); + hasLink = true; + } + wrappers.push(child.name); + } + return null; +} + +function isImageBlockWrapper(token: HtmlToken | undefined): boolean { + return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div"); } function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean { + if (isImageBlockWrapper(tokens[start])) { + return false; + } + const previous = tokens[start - 1]; // At line start if nothing precedes this image, or the preceding token ends with only // whitespace since the last newline (covers a bare space between two images on the same line). @@ -191,6 +239,9 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number) } // An inline image tag — skip over it (the image itself and its possible wrapping close tag). if (token.kind === "tag") { + if (isImageBlockWrapper(token)) { + return false; + } const imageResult = parseInlineImageAt(tokens, cursor); if (imageResult) { cursor = imageResult.end; From 5f28b3cf8cda5bcd0cb390e49cc61f73811651db Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 14:41:32 +0100 Subject: [PATCH 011/281] fix(app): smooth chat history pagination and reserve image space (#5970) * fix(app): smooth chat history pagination and reserve image space Select the range at the pending reading offset before a prepend mounts rows. Reuse committed geometry for scroll input so a layout correction cannot hide the user's movement. Reserve image dimensions before loading and measure transient mounts in the browser regression. * fix(app): scope cached image dimensions to their source Raw file-path aliases let one workspace supply dimensions for another. Keep file metadata tied to its daemon and workspace, and close the delayed-image test server even when setup fails. * fix(app): retain local image context in row height estimates Pass the rendering server and workspace through height estimation so scoped image metadata remains usable before an older row mounts. * refactor(app): name row height estimate inputs --- .../agent-timeline-scroll-smoothness.spec.ts | 5 + .../helpers/timeline-scroll-smoothness.ts | 123 ++++++++++++++++-- .../src/agent-stream/reading-anchor.test.ts | 29 +++++ .../app/src/agent-stream/reading-anchor.ts | 27 +++- .../src/agent-stream/strategy-web.test.tsx | 101 ++++++-------- .../app/src/agent-stream/strategy-web.tsx | 78 ++++++----- packages/app/src/agent-stream/strategy.ts | 2 + packages/app/src/agent-stream/view.tsx | 1 + .../agent-stream/web-virtualization.test.ts | 53 +++++++- .../src/agent-stream/web-virtualization.ts | 21 ++- .../assistant-image/use-assistant-image.ts | 14 +- .../components/markdown/inline-image-size.ts | 3 +- .../src/components/markdown/renderer.test.ts | 6 +- .../app/src/components/markdown/renderer.tsx | 18 ++- packages/app/src/components/message.tsx | 15 +-- .../utils/assistant-image-metadata.test.ts | 80 ++++++++---- .../app/src/utils/assistant-image-metadata.ts | 117 +++++++---------- .../assistant-message-height-estimate.test.ts | 28 ++-- .../assistant-message-height-estimate.ts | 16 ++- 19 files changed, 490 insertions(+), 247 deletions(-) diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index ee4201ea75..feceed7869 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -2,6 +2,7 @@ import { test } from "../support/fixtures"; import { expect } from "@playwright/test"; import { findScrollJumps, + expectImageSpaceReserved, type ScrollFrame, observeTimelinePages, openOnlyTimelineTail, @@ -145,3 +146,7 @@ for (const cadence of scrollCadences) { }); }); } + +test("reserves image space before its response arrives", async ({ page }, testInfo) => { + await expectImageSpaceReserved(page, testInfo); +}); diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index f550b02fdc..01055ce2c1 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -171,9 +171,27 @@ export async function recordUpwardTraversal( let lastWheelAt = -Infinity; let inputFinishedAt: number | null = null; let imageLoads = 0; - let mounted = 0; + let mounted = scroll.querySelectorAll("[data-history-row-id]").length; let unmounted = 0; - let previousIds = new Set(); + const collectRows = (node: Node, rows: Set) => { + if (!(node instanceof Element)) return; + if (node.matches("[data-history-row-id]")) rows.add(node); + for (const row of node.querySelectorAll("[data-history-row-id]")) rows.add(row); + }; + const recordMutations = (records: MutationRecord[]) => { + // A newly inserted container and its children can both appear in one + // delivery. Count each DOM row once, including rows removed before paint. + const added = new Set(); + const removed = new Set(); + for (const record of records) { + for (const node of record.addedNodes) collectRows(node, added); + for (const node of record.removedNodes) collectRows(node, removed); + } + mounted += added.size; + unmounted += removed.size; + }; + const mutations = new MutationObserver(recordMutations); + mutations.observe(scroll, { childList: true, subtree: true }); const wheel = (event: WheelEvent) => { wheelTotal += -event.deltaY; lastWheelAt = performance.now(); @@ -205,10 +223,8 @@ export async function recordUpwardTraversal( const box = row.getBoundingClientRect(); return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height }; }); - const ids = new Set(rows.map((row) => row.id)); - mounted += [...ids].filter((id) => !previousIds.has(id)).length; - unmounted += [...previousIds].filter((id) => !ids.has(id)).length; - previousIds = ids; + // Include rows mounted and removed in the same commit, before sampling. + recordMutations(mutations.takeRecords()); // Preserve the row crossing the reading line. Rows below an expanding // visible image legitimately move even when the reading position is stable. const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8); @@ -246,6 +262,7 @@ export async function recordUpwardTraversal( stop() { cancelAnimationFrame(frame); resizeObserver.disconnect(); + mutations.disconnect(); scroll.removeEventListener("wheel", wheel); scroll.removeEventListener("load", imageLoad, true); }, @@ -362,9 +379,9 @@ export async function reportScrollJumps( worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)), frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)], frameGapMaxMs: gaps.at(-1), - imageLoads: frames.at(-1)?.imageLoads, - mounted: frames.at(-1)?.mounted, - unmounted: frames.at(-1)?.unmounted, + imageLoads: frames[frames.length - 1].imageLoads, + mounted: frames[frames.length - 1].mounted, + unmounted: frames[frames.length - 1].unmounted, jumps, }; await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2)); @@ -382,7 +399,7 @@ export async function reportScrollJumps( body: JSON.stringify(react), contentType: "application/json", }); - await testInfo.attach("scroll-end", { body: await page.screenshot(), contentType: "image/png" }); + await attachTimelineScreenshot(page, testInfo, "scroll-end"); console.log( `[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`, ); @@ -395,6 +412,15 @@ export async function reportScrollJumps( "must exercise virtualization", ).toBe(true); expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0); + expect( + summary.mounted - summary.unmounted, + "row counters must balance against the final DOM", + ).toBe(frames[frames.length - 1].rows.length); + const uniqueRows = new Set(frames.flatMap((frame) => frame.rows.map((row) => row.id))).size; + expect( + summary.mounted, + "prepending must not mount a discarded range before correcting the viewport", + ).toBeLessThanOrEqual(uniqueRows * 1.25); expect( jumps, "visible text must not reverse direction while scrolling upward or move after input stops", @@ -432,3 +458,80 @@ async function traceScrollIfRequested( await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" }); }; } + +/** Hold real image bytes until the reader has reached its placeholder. */ +export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo): Promise { + let released = false; + const pending = new Set<() => void>(); + const server = createServer((_request, response) => { + const send = () => { + response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" }); + response.end( + '', + ); + }; + if (released) send(); + else pending.add(send); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Image server did not listen"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "reserved-timeline-image-", + title: "Reserved image geometry", + featureValues: { + mockAssistantResponses: [ + `Before the image.\n\n![Reserved image](http://127.0.0.1:${address.port}/image.svg)\n\nText below the image.`, + ], + }, + }); + try { + await agent.client.sendAgentMessage(agent.agentId, "Show the delayed image"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, "Show the delayed image"); + const image = page.getByRole("img", { name: "Reserved image" }).first(); + await expect(image).toBeVisible(); + const before = await image.boundingBox(); + await attachTimelineScreenshot(page, testInfo, "image-placeholder"); + released = true; + for (const send of pending) send(); + await expect + .poll(() => + image.evaluate((element) => { + const img = + element instanceof HTMLImageElement ? element : element.querySelector("img"); + return img?.naturalWidth; + }), + ) + .toBe(240); + await expect + .poll(async () => { + const box = await image.boundingBox(); + return box ? Math.round((box.width / box.height) * 100) : 0; + }) + .toBe(150); + const after = await image.boundingBox(); + await attachTimelineScreenshot(page, testInfo, "image-loaded"); + expect(before?.height, "reserve the final image height before the HTTP response").toBe( + after?.height, + ); + } finally { + await agent.cleanup(); + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +async function attachTimelineScreenshot( + page: Page, + testInfo: TestInfo, + name: string, +): Promise { + const path = testInfo.outputPath(`${name}.png`); + await page.getByTestId("agent-chat-scroll").screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); +} diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 19b6bc9e31..74b2bd5bcd 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -78,6 +78,35 @@ describe("reading anchor", () => { ).toBe(2600); }); + it("projects a prepend range without consuming the correction before layout commits", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + true, + ), + ).toBe(2440); + expect(anchor.getRowId()).toBe("above"); + }); + + it("repicks from user movement before applying a simultaneous layout correction", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.scroll(440); + expect(anchor.getRowId()).toBe("above"); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + ), + ).toBe(2440); + expect(anchor.getRowId()).toBe("above"); + }); + it("releases the old reading position for explicit navigation", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 31b965f134..4299f1d9c8 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -4,27 +4,40 @@ interface RowGeometry { height: number; } -// Store content coordinates, never a frozen viewport offset: user scrolling must -// survive a layout correction, including input delivered while a page is loading. +// A row must clear the reading line before the next row takes ownership. +const READING_POSITION_OFFSET_PX = 8; + +// Content coordinates survive user scrolling. Layout commits replace the geometry; +// scroll events reuse it, without another DOM measurement pass. export function createReadingAnchor() { let anchor: { id: string; top: number } | null = null; + let geometry: readonly RowGeometry[] = []; + const readingRow = (scrollTop: number) => + geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); + const project = (scrollTop: number, row: Pick | undefined) => + scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); return { getRowId: () => anchor?.id ?? null, + getReadingRowId: (scrollTop: number) => + readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, + project, reset() { anchor = null; }, + scroll(scrollTop: number) { + const next = readingRow(scrollTop); + anchor = next ? { id: next.id, top: next.top } : null; + }, reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + geometry = rows; const previous = anchor && rows.find((row) => row.id === anchor?.id); // The first virtualized commit can precede its mounted range. Keep the // pinned reader until it mounts, rather than adopting an unrelated row. if (anchor && !previous && !userScrolled) return scrollTop; - const correctedTop = scrollTop + (previous && anchor ? previous.top - anchor.top : 0); + const correctedTop = project(scrollTop, previous ?? undefined); // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. - const next = - previous && !userScrolled - ? previous - : rows.find((row) => row.top + row.height > correctedTop + 8); + const next = previous && !userScrolled ? previous : readingRow(correctedTop); anchor = next ? { id: next.id, top: next.top } : null; return correctedTop; }, diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index c0fe52e3fc..9078b0f1c3 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -491,10 +491,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 }); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4959.1708984375, - }); + scrollContainer.scrollTop = 4959.1708984375; scrollTo.mockClear(); act(() => { @@ -504,10 +501,7 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4960.5, - }); + scrollContainer.scrollTop = 4960.5; act(() => { viewportRef.current?.scrollToBottom("message-sent"); @@ -516,10 +510,7 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4967, - }); + scrollContainer.scrollTop = 4967; act(() => { viewportRef.current?.scrollToBottom("message-sent"); @@ -535,7 +526,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); @@ -585,7 +576,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { root?.render( @@ -621,10 +612,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -682,7 +670,7 @@ describe("createWebStreamStrategy", () => { const scrollElement = scrollContainer; Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 }); - Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 }); + scrollElement.scrollTop = 0; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -724,10 +712,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -786,7 +771,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -798,7 +783,7 @@ describe("createWebStreamStrategy", () => { act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -831,10 +816,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -892,7 +874,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -901,17 +883,17 @@ describe("createWebStreamStrategy", () => { }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 }); + scrollContainer.scrollTop = 991; act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 }); + scrollContainer.scrollTop = 91; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -937,10 +919,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -992,7 +971,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + scrollContainer.scrollTop = 50; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -1004,13 +983,13 @@ describe("createWebStreamStrategy", () => { act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 }); + scrollContainer.scrollTop = 20; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + scrollContainer.scrollTop = 50; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -1038,7 +1017,7 @@ describe("createWebStreamStrategy", () => { it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => { const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1084,7 +1063,7 @@ describe("createWebStreamStrategy", () => { Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; scrollContainer.getBoundingClientRect = () => ({ bottom: 500, @@ -1124,7 +1103,7 @@ describe("createWebStreamStrategy", () => { window.dispatchEvent(jitterPointerMove); }); Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 }); + scrollContainer.scrollTop = 800; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1132,11 +1111,11 @@ describe("createWebStreamStrategy", () => { nestedScroller.style.overflowY = "auto"; Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 }); Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 }); - Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 }); + nestedScroller.scrollTop = 100; scrollContainer.append(nestedScroller); act(() => { nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 })); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 }); + scrollContainer.scrollTop = 700; scrollContainer.dispatchEvent(new Event("scroll")); scrollContainer.dispatchEvent( new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }), @@ -1159,7 +1138,7 @@ describe("createWebStreamStrategy", () => { it("stops following output after scrollbar and middle-button upward scrolling", () => { const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1204,7 +1183,7 @@ describe("createWebStreamStrategy", () => { Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; scrollContainer.getBoundingClientRect = () => ({ bottom: 500, @@ -1232,7 +1211,7 @@ describe("createWebStreamStrategy", () => { pointerType: { value: "mouse" }, }); act(() => scrollContainer.dispatchEvent(scrollbarPointerDown)); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 }); + scrollContainer.scrollTop = 700; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1248,7 +1227,7 @@ describe("createWebStreamStrategy", () => { }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 }); + scrollContainer.scrollTop = 1300; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 }); scrollContainer.getBoundingClientRect = () => @@ -1276,7 +1255,7 @@ describe("createWebStreamStrategy", () => { pointerType: { value: "mouse" }, }); act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown)); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 }); + scrollContainer.scrollTop = 900; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1295,7 +1274,7 @@ describe("createWebStreamStrategy", () => { }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 }); + scrollContainer.scrollTop = 1400; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); const autoscrollPointerDown = new MouseEvent("pointerdown", { bubbles: true, @@ -1329,7 +1308,7 @@ describe("createWebStreamStrategy", () => { window.dispatchEvent(autoscrollPointerUp); window.dispatchEvent(autoscrollPointerMove); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1390,7 +1369,7 @@ describe("createWebStreamStrategy", () => { }; const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1447,12 +1426,18 @@ describe("createWebStreamStrategy", () => { throw new Error("Expected retained history rows"); } let rowsShifted = false; - scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect); - firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect); - secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect); + scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect; + firstRow.getBoundingClientRect = () => + ({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + top: rowsShifted ? 4 : 120, + height: rowsShifted ? 116 : 40, + bottom: rowsShifted ? 120 : 160, + }) as DOMRect; Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); const viewportObservation = observed.get(scrollContainer); expect(viewportObservation).toBeDefined(); @@ -1483,7 +1468,7 @@ describe("createWebStreamStrategy", () => { act(() => root?.render(renderWithActivity(false))); scrollTo.mockClear(); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); scrollContainer.dispatchEvent(new Event("scroll")); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index eee4557ab7..25e7a313d2 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -13,6 +13,7 @@ import { measureElement as measureVirtualElement, useVirtualizer, type Range as VirtualRange, + type Virtualizer, } from "@tanstack/react-virtual"; import { flushSync } from "react-dom"; import { withUnistyles } from "react-native-unistyles"; @@ -61,9 +62,6 @@ const HISTORY_START_SLOT_HEIGHT_PX = 32; const CONTENT_PADDING_TOP_PX = 16; const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100; const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX; -// A row has to clear this much of the viewport top before the next one takes over as the -// reading position, so a row resting exactly on the edge does not flip back and forth. -const READING_POSITION_OFFSET_PX = 8; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -289,6 +287,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool olderHistoryProgressKey, scrollEnabled, contentMaxWidth, + imageContext, isMobileBreakpoint, } = props; const historyVirtualized = useRevisedHistoryRows( @@ -373,15 +372,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); return indexes.length > 0 ? indexes : null; }, [chatFindMessageId, segments.historyVirtualized]); + const rowVirtualizerRef = useRef | null>(null); const rangeExtractor = useCallback( - (range: VirtualRange) => { - const visible = defaultRangeExtractor(range); + (range: VirtualRange): number[] => { const anchorIndex = segments.historyVirtualized.findIndex( (row) => row.id === readingAnchor.getRowId(), ); + const container = scrollContainerRef.current; + // TanStack has computed the new measurements before calling the extractor. + // Select against the pending anchor correction, before mounting any rows at + // the stale pre-prepend offset. The DOM correction still belongs to commit. + const virtualizer = rowVirtualizerRef.current!; + const measurement = virtualizer.measurementsCache[anchorIndex]; + const correctedOffset = + container && measurement + ? readingAnchor.project(container.scrollTop, { + id: String(measurement.key), + top: measurement.start, + }) + : container?.scrollTop; + let visibleRange = range; + if ( + container && + correctedOffset !== undefined && + Math.abs(correctedOffset - container.scrollTop) > 0.5 + ) { + const first = virtualizer.getVirtualItemForOffset(correctedOffset); + const last = virtualizer.getVirtualItemForOffset(correctedOffset + container.clientHeight); + if (first && last) + visibleRange = { ...range, startIndex: first.index, endIndex: last.index }; + } return [ ...new Set([ - ...visible, + ...defaultRangeExtractor(visibleRange), ...(chatFindRowIndexes ?? []), ...(anchorIndex < 0 ? [] : [anchorIndex]), ]), @@ -398,6 +421,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool unsubscribe?.(); }; }, []); + const contentWidth = contentRef.current?.getBoundingClientRect().width; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, @@ -406,15 +430,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; const measured = row && measuredRowHeights.current.get(row.id); - const width = contentRef.current?.getBoundingClientRect().width; - if (measured && measured.width === width) return measured.height; - return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; + if (measured && measured.width === contentWidth) return measured.height; + return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120; }, observeElementOffset: observeVirtualOffset, rangeExtractor, scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, overscan: 8, }); + rowVirtualizerRef.current = rowVirtualizer; // Scroll correction belongs to the viewport's committed layout. The virtualizer // otherwise writes scrollTop before React applies its new row positions. rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; @@ -646,19 +670,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onReadingPositionChange(null); return; } - const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX; - let readingRowId: string | null = null; - for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { - const rowId = element.dataset.historyRowId; - if (!rowId) { - continue; - } - readingRowId = rowId; - if (element.getBoundingClientRect().bottom > readingLine) { - break; - } - } - onReadingPositionChange(readingRowId); + onReadingPositionChange(readingAnchor.getReadingRowId(scrollContainer.scrollTop)); }); const updateScrollMetrics = useCallback(() => { @@ -684,7 +696,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); - const reconcileReadingPosition = useStableEvent((userScrolled: boolean = false) => { + const reconcileReadingPosition = useStableEvent(() => { const container = scrollContainerRef.current; const content = contentRef.current; if (!isActiveRef.current || !container || !content) return; @@ -700,11 +712,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; }, ); - if (followOutputRef.current || isJumpSettling()) { + const shouldAnchor = !followOutputRef.current && !isJumpSettling(); + if (!shouldAnchor) readingAnchor.reset(); + const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + if (!shouldAnchor) { readingAnchor.reset(); return; } - const correctedTop = readingAnchor.reconcile(container.scrollTop, rows, userScrolled); if (Math.abs(correctedTop - container.scrollTop) > 0.5) { container.scrollTop = correctedTop; lastKnownScrollTopRef.current = container.scrollTop; @@ -718,7 +732,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // the ordinary-to-virtualized handoff and measurements of newly mounted rows. useLayoutEffect(() => { reconcileReadingPosition(); - }); + }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); @@ -752,13 +766,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } lastKnownScrollTopRef.current = currentScrollTop; - reconcileReadingPosition(scrolledUp || scrolledDown); + if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) { + readingAnchor.scroll(currentScrollTop); + } updateScrollMetrics(); evaluateHistoryStart(); }, [ evaluateHistoryStart, isJumpSettling, - reconcileReadingPosition, + readingAnchor, stopFollowingOutputFromUserIntent, updateScrollMetrics, ]); @@ -1051,7 +1067,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastTouchClientYRef.current = null; }; - scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true }); + // Read user movement before the virtualizer's bubbling listener flushes a + // layout correction. Scroll events reuse the last committed row geometry. + scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true, capture: true }); scrollContainer.addEventListener("wheel", handleWheel, { passive: true }); scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true }); scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); @@ -1065,7 +1083,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { - scrollContainer.removeEventListener("scroll", handleDomScroll); + scrollContainer.removeEventListener("scroll", handleDomScroll, true); scrollContainer.removeEventListener("wheel", handleWheel); scrollContainer.removeEventListener("keydown", handleKeyDown); scrollContainer.removeEventListener("pointerdown", handlePointerDown); diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f43f8f6b1..3b77f6b831 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,3 +1,4 @@ +import type { AssistantImageContext } from "@/utils/assistant-image-metadata"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; @@ -94,6 +95,7 @@ export interface StreamRenderInput { forwardListContentContainerStyle: StyleProp; // The chat column's max width. The web viewport estimates unmeasured row heights with it. contentMaxWidth: number; + imageContext?: AssistantImageContext; } export interface ResolveStreamRenderStrategyInput { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index bf13ca6330..12f628da4f 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1131,6 +1131,7 @@ const AgentStreamViewComponent = forwardRef { describe("estimateStreamItemHeight", () => { it("uses compact estimates for collapsed tool sequence rows", () => { - expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); - expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); + expect( + estimateStreamItemHeight({ + item: toolCall("tool", 1), + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBe(40); + expect( + estimateStreamItemHeight({ + item: thought("thought", 2), + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBe(40); }); it("uses a larger estimate for user messages with image attachments", () => { @@ -146,7 +156,40 @@ describe("estimateStreamItemHeight", () => { ], }; - expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220); + expect(estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH })).toBe( + 220, + ); + }); + + it("uses local image dimensions only from the row's server and workspace", () => { + clearAssistantImageMetadataCache(); + const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" }; + setAssistantImageMetadata( + { source: "portrait.png", ...imageContext }, + { width: 800, height: 1600 }, + ); + const item: StreamItem = { + kind: "assistant_message", + id: "local-image", + text: "![Portrait](portrait.png)", + timestamp: createTimestamp(2), + }; + + expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264); + expect( + estimateStreamItemHeight({ + item, + contentMaxWidth: 608, + imageContext: { ...imageContext, workspaceRoot: "/workspace/two" }, + }), + ).toBe(464); + expect( + estimateStreamItemHeight({ + item, + contentMaxWidth: 608, + imageContext: { ...imageContext, serverId: "server-two" }, + }), + ).toBe(464); }); it("uses cached assistant image metadata when available", () => { @@ -165,7 +208,9 @@ describe("estimateStreamItemHeight", () => { timestamp: createTimestamp(2), }; - expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220); + expect( + estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }), + ).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index 5445783178..0c7f756959 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -1,3 +1,4 @@ +import type { AssistantImageContext } from "@/utils/assistant-image-metadata"; import type { StreamItem } from "@/types/stream"; import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate"; import { @@ -43,12 +44,28 @@ export interface WebVirtualizedHistoryWindow { mountedEntries: IndexedStreamItem[]; } -export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number { +interface StreamItemHeightEstimateInput { + item: StreamItem; + contentMaxWidth: number; + imageContext?: AssistantImageContext; +} + +export function estimateStreamItemHeight({ + item, + contentMaxWidth, + imageContext, +}: StreamItemHeightEstimateInput): number { switch (item.kind) { case "user_message": return item.images && item.images.length > 0 ? 220 : 96; case "assistant_message": - return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220; + return ( + estimateAssistantMessageHeightFromCache({ + markdown: item.text, + contentMaxWidth, + imageContext, + }) ?? 220 + ); case "tool_call": return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "thought": diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 0c0818e8f1..3314b7a398 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -415,8 +415,8 @@ export function useAssistantImage({ const preview = dataImage ? dataImagePreview : filePreview; const previewUri = preview.status === "loaded" ? preview.uri : null; const uri = directUri ?? previewUri; - const cachedMetadata = useMemo( - () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), + const cachedAspectRatio = useMemo( + () => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null, [serverId, source, workspaceRoot], ); const [lifecycle, dispatchLifecycle] = useReducer( @@ -454,9 +454,9 @@ export function useAssistantImage({ dispatch({ type: "preview_created", uri, - aspectRatio: cachedMetadata?.aspectRatio ?? null, + aspectRatio: cachedAspectRatio, }); - }, [cachedMetadata, dispatch, uri]); + }, [cachedAspectRatio, dispatch, uri]); const handleImageError = useCallback(() => { if (uri) { @@ -484,7 +484,7 @@ export function useAssistantImage({ const metadata = dimensions ? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions) : null; - const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null; + const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio; if (!aspectRatio) { dispatch({ type: "failed", @@ -495,7 +495,7 @@ export function useAssistantImage({ } dispatch({ type: "image_loaded", uri, aspectRatio }); }, - [cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot], + [cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot], ); const acquisitionFailure = getAcquisitionFailure({ @@ -539,6 +539,6 @@ export function useAssistantImage({ return { status: "loading", binding, - aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null, + aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio, }; } diff --git a/packages/app/src/components/markdown/inline-image-size.ts b/packages/app/src/components/markdown/inline-image-size.ts index c4f4b67ff3..8f83f80fcc 100644 --- a/packages/app/src/components/markdown/inline-image-size.ts +++ b/packages/app/src/components/markdown/inline-image-size.ts @@ -8,7 +8,6 @@ export interface InlineImageExplicitDimensions { height?: number; } -const INLINE_IMAGE_FALLBACK_SIZE = 16; const INLINE_IMAGE_MAX_WIDTH = 240; const INLINE_IMAGE_MAX_HEIGHT = 160; @@ -59,5 +58,5 @@ function resolveInlineImageDimensions(input: { return { width: input.explicit.height, height: input.explicit.height }; } - return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE }; + return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT }; } diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 1dc279a2d5..2dcab5c327 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -63,10 +63,10 @@ describe("resolveInlineImageSize", () => { }); }); - it("uses a generic small fallback when no dimensions are known", () => { + it("reserves a capped 3:2 placeholder when no dimensions are known", () => { expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({ - width: 16, - height: 16, + width: 240, + height: 160, }); }); }); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index fecd9026ab..83380ea919 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -39,6 +39,10 @@ import { type MarkdownInlineImagePart, } from "./html-ish"; import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size"; +import { + getAssistantImageMetadata, + setAssistantImageMetadata, +} from "@/utils/assistant-image-metadata"; import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups"; import { colorMarkdownLinkChildren } from "./link-children"; import { MarkdownLinkText } from "./link-text"; @@ -201,11 +205,16 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { failed: boolean; setFailed: (failed: boolean) => void; } { - const [natural, setNatural] = useState(null); + const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]); + const [resolved, setResolved] = useState<{ + source: string; + dimensions: InlineImageDimensions; + } | null>(null); + const natural = resolved?.source === part.src ? resolved.dimensions : cached; const [failed, setFailed] = useState(false); useEffect(() => { - if (part.width && part.height) { + if ((part.width && part.height) || cached) { return; } @@ -213,8 +222,9 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { Image.getSize( part.src, (width, height) => { + setAssistantImageMetadata({ source: part.src }, { width, height }); if (!cancelled) { - setNatural({ width, height }); + setResolved({ source: part.src, dimensions: { width, height } }); } }, () => { @@ -227,7 +237,7 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { return () => { cancelled = true; }; - }, [part.height, part.src, part.width]); + }, [cached, part.height, part.src, part.width]); return { natural, failed, setFailed }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4ae2ccd1b7..8d93f4588b 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { TaskListRow } from "@/components/task-list-row"; import { @@ -814,8 +815,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -const ASSISTANT_IMAGE_MIN_HEIGHT = 160; - function AssistantMarkdownImage({ source, occurrenceKey, @@ -860,11 +859,9 @@ function AssistantMarkdownImage({ [containerStyle], ); const imageSizeStyle = useMemo(() => { - if (aspectRatio) { - return { aspectRatio }; - } - return { height: ASSISTANT_IMAGE_MIN_HEIGHT }; - }, [aspectRatio]); + if (image.status === "failed") return { height: 160 }; + return { aspectRatio: aspectRatio ?? ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO }; + }, [aspectRatio, image.status]); const surfaceStyle = useMemo>( () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], [imageSizeStyle], @@ -882,10 +879,10 @@ function AssistantMarkdownImage({ () => [ assistantMessageStylesheet.imageFrame, containerStyle, - { height: ASSISTANT_IMAGE_MIN_HEIGHT }, + imageSizeStyle, assistantMessageStylesheet.imageState, ], - [containerStyle], + [containerStyle, imageSizeStyle], ); if (image.status === "failed") { diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index 8f8cca2d3f..3ebee656dd 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -21,25 +21,53 @@ describe("assistant image metadata", () => { ).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]); }); - it("reuses cached metadata across canonical and raw source keys", () => { - setAssistantImageMetadata( - { - source: "/tmp/paseo-codex-screenshot.png", - workspaceRoot: "/workspaces/paseo", - serverId: "server-1", - }, - { width: 1200, height: 800 }, - ); + it("keeps local image metadata scoped to its server and workspace", () => { + const image = { + source: "screenshot.png", + workspaceRoot: "/workspace/one", + serverId: "server-1", + }; + setAssistantImageMetadata(image, { width: 1200, height: 800 }); - expect( - getAssistantImageMetadata({ - source: "/tmp/paseo-codex-screenshot.png", - }), - ).toEqual({ + expect(getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })).toBeNull(); + expect(getAssistantImageMetadata({ ...image, serverId: "server-2" })).toBeNull(); + expect(getAssistantImageMetadata({ source: image.source })).toBeNull(); + expect(getAssistantImageMetadata(image)).toEqual({ width: 1200, height: 800, aspectRatio: 1.5, }); + + setAssistantImageMetadata( + { ...image, workspaceRoot: "/workspace/two" }, + { width: 800, height: 1200 }, + ); + expect(getAssistantImageMetadata(image)?.aspectRatio).toBe(1.5); + expect( + getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })?.aspectRatio, + ).toBe(2 / 3); + }); + + it("reuses direct image metadata without workspace context", () => { + const source = "https://example.com/shared.png"; + setAssistantImageMetadata( + { source, workspaceRoot: "/workspace/one", serverId: "server-1" }, + { width: 1200, height: 800 }, + ); + expect(getAssistantImageMetadata({ source })).toEqual({ + width: 1200, + height: 800, + aspectRatio: 1.5, + }); + }); + + it("reserves the default image aspect ratio before metadata arrives", () => { + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "![Pending](https://example.com/pending.png)", + contentMaxWidth: 608, + }), + ).toBe(464); }); it("estimates assistant message height from cached image metadata", () => { @@ -51,10 +79,10 @@ describe("assistant image metadata", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); @@ -62,14 +90,14 @@ describe("assistant image metadata", () => { const source = `data:image/png;base64,${"a".repeat(512)}`; setAssistantImageMetadata({ source }, { width: 1200, height: 800 }); - const imageOnlyHeight = estimateAssistantMessageHeightFromCache( - `![Screenshot](${source})`, - DEFAULT_CONTENT_MAX_WIDTH, - ); - const mixedHeight = estimateAssistantMessageHeightFromCache( - `Text\n\n![Screenshot](${source})`, - DEFAULT_CONTENT_MAX_WIDTH, - ); + const imageOnlyHeight = estimateAssistantMessageHeightFromCache({ + markdown: `![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); + const mixedHeight = estimateAssistantMessageHeightFromCache({ + markdown: `Text\n\n![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); expect(imageOnlyHeight).toBeGreaterThan(220); expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0); diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 76b8eafbb1..ac7bd217b2 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -1,6 +1,11 @@ import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { createImageSourceCacheKey } from "@/attachments/utils"; +export interface AssistantImageContext { + serverId?: string; + workspaceRoot?: string; +} + export interface AssistantImageMetadata { width: number; height: number; @@ -13,6 +18,7 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500; const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g; +export const ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO = 3 / 2; const ASSISTANT_IMAGE_INSET = 8; const ASSISTANT_IMAGE_MIN_HEIGHT = 160; const ASSISTANT_IMAGE_BLOCK_GAP = 24; @@ -65,15 +71,9 @@ function parseAssistantImageMarkdown(markdown: string): { }; } -function createSourceAliasKey(source: string): string { - return `source:${createImageSourceCacheKey(source)}`; -} - -function createResolutionKey(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string | null { +function getAssistantImageMetadataKey( + input: AssistantImageContext & { source: string }, +): string | null { const resolution = resolveAssistantImageSource({ source: input.source, workspaceRoot: input.workspaceRoot, @@ -84,53 +84,28 @@ function createResolutionKey(input: { if (resolution.kind === "direct") { return `direct:${createImageSourceCacheKey(resolution.uri)}`; } - return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`; + // A file path identifies an image only within its daemon and workspace. + // Callers without that context must use the default aspect ratio. + if (!input.serverId) { + return null; + } + return JSON.stringify(["file", input.serverId, resolution.cwd, resolution.path]); } -function getAssistantImageMetadataKeys(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string[] { - const source = input.source.trim(); - if (!source) { - return []; +export function getAssistantImageMetadata( + input: AssistantImageContext & { source: string }, +): AssistantImageMetadata | null { + const key = getAssistantImageMetadataKey(input); + const metadata = key ? assistantImageMetadataCache.get(key) : undefined; + if (!key || !metadata) { + return null; } - - const keys = [createSourceAliasKey(source)]; - const resolutionKey = createResolutionKey(input); - if (resolutionKey) { - keys.unshift(resolutionKey); - } - return [...new Set(keys)]; -} - -export function getAssistantImageMetadata(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): AssistantImageMetadata | null { - for (const key of getAssistantImageMetadataKeys(input)) { - const metadata = assistantImageMetadataCache.get(key); - if (metadata) { - touchCacheEntry( - assistantImageMetadataCache, - key, - metadata, - ASSISTANT_IMAGE_METADATA_CACHE_LIMIT, - ); - return metadata; - } - } - return null; + touchCacheEntry(assistantImageMetadataCache, key, metadata, ASSISTANT_IMAGE_METADATA_CACHE_LIMIT); + return metadata; } export function setAssistantImageMetadata( - input: { - source: string; - workspaceRoot?: string; - serverId?: string; - }, + input: AssistantImageContext & { source: string }, dimensions: { width: number; height: number }, ): AssistantImageMetadata | null { const { width, height } = dimensions; @@ -144,7 +119,8 @@ export function setAssistantImageMetadata( aspectRatio: width / height, }; - for (const key of getAssistantImageMetadataKeys(input)) { + const key = getAssistantImageMetadataKey(input); + if (key) { touchCacheEntry( assistantImageMetadataCache, key, @@ -176,28 +152,31 @@ export function extractAssistantImageSources(markdown: string): string[] { return parsed.sources; } -export function estimateAssistantMessageHeightFromCache( - markdown: string, - contentMaxWidth: number, -): number | null { +export interface AssistantMessageHeightEstimateInput { + markdown: string; + contentMaxWidth: number; + imageContext?: AssistantImageContext; +} + +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown); if (parsed.sources.length === 0) { return null; } - const knownHeights = parsed.sources - .map((source) => getAssistantImageMetadata({ source })) - .filter((metadata): metadata is AssistantImageMetadata => metadata !== null) - .map((metadata) => - Math.max( - ASSISTANT_IMAGE_MIN_HEIGHT, - Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / metadata.aspectRatio), - ), + const imageHeights = parsed.sources.map((source) => { + const aspectRatio = + getAssistantImageMetadata({ source, ...imageContext })?.aspectRatio ?? + ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO; + return Math.max( + ASSISTANT_IMAGE_MIN_HEIGHT, + Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / aspectRatio), ); - - if (knownHeights.length === 0) { - return null; - } + }); const baseHeight = parsed.hasNonImageText ? ASSISTANT_MESSAGE_BASE_HEIGHT @@ -205,8 +184,8 @@ export function estimateAssistantMessageHeightFromCache( const estimatedHeight = baseHeight + - knownHeights.reduce((sum, height) => sum + height, 0) + - ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length; + imageHeights.reduce((sum, height) => sum + height, 0) + + ASSISTANT_IMAGE_BLOCK_GAP * imageHeights.length; return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight); } diff --git a/packages/app/src/utils/assistant-message-height-estimate.test.ts b/packages/app/src/utils/assistant-message-height-estimate.test.ts index 8df70fe588..d4e3a2ff94 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.test.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.test.ts @@ -29,19 +29,27 @@ describe("assistant message height estimate", () => { }); expect( - estimateAssistantMessageHeightFromCache( - "First paragraph\n\nSecond paragraph", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "First paragraph\n\nSecond paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBe(97); }); it("reads block heights measured at the configured content width", () => { setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 }); - expect(estimateAssistantMessageHeightFromCache("Wide paragraph", 1600)).toBe(44); expect( - estimateAssistantMessageHeightFromCache("Wide paragraph", DEFAULT_CONTENT_MAX_WIDTH), + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: 1600, + }), + ).toBe(44); + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeNull(); }); @@ -54,10 +62,10 @@ describe("assistant message height estimate", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts index 638c961e01..02e153e0bb 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.ts @@ -1,4 +1,7 @@ -import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata"; +import { + type AssistantMessageHeightEstimateInput, + estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache, +} from "@/utils/assistant-image-metadata"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000; @@ -106,13 +109,14 @@ function estimateAssistantMarkdownBlockHeightFromCache( ); } -export function estimateAssistantMessageHeightFromCache( - markdown: string, - contentMaxWidth: number, -): number | null { +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { return ( estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ?? - estimateAssistantImageMessageHeightFromCache(markdown, contentMaxWidth) + estimateAssistantImageMessageHeightFromCache({ markdown, contentMaxWidth, imageContext }) ); } From b5e9747e2a59b694a3e4d827202d686c2e4612c9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 16:30:13 +0100 Subject: [PATCH 012/281] Add the reviewed plugin registry and the unlinked plugin directory pages (#5986) * feat(plugins): install reviewed artifacts from open registries * fix(website): build protocol before deployment checks * fix(website): make plugin pages discoverable to search engines Resolve missing registry entries in route loaders to return HTTP 404. Serve a registry-backed sitemap so newly listed plugins and authors are discoverable without rebuilding the website. * feat(website): keep plugin directory unlinked for coordinated launch Preserve direct routes and SEO while retaining the partner directory link. Cover nested Git artifacts through registry installation and pinned website commands. * fix(website): link plugin cards by registry owner and stub the clipboard in e2e Plugin and author URLs come from the owner segment of the plugin ID, not the author's GitHub login, so a listed plugin always resolves. The Playwright spec records clipboard writes instead of granting a permission WebKit does not have. --- .github/workflows/deploy-website.yml | 6 +- docs/plugins.md | 40 ++- package-lock.json | 3 +- packages/cli/src/commands/plugin/index.ts | 2 +- packages/protocol/src/messages.ts | 15 +- packages/protocol/src/plugin-registry.ts | 75 +++++ .../src/plugin-source-reference.test.ts | 18 ++ .../protocol/src/plugin-source-reference.ts | 3 + packages/server/src/server/bootstrap.ts | 8 +- .../server/src/server/config-environment.ts | 1 + packages/server/src/server/config.test.ts | 15 + packages/server/src/server/config.ts | 2 + .../server/src/server/persisted-config.ts | 2 + .../plugins/managed-source.posix.test.ts | 177 +++++++++++- .../src/server/plugins/managed-source.ts | 87 +++++- .../server/plugins/managed-source/registry.ts | 54 ++++ packages/website/e2e/plugins.spec.ts | 216 +++++++++++++++ packages/website/package.json | 1 + packages/website/public/robots.txt | 4 + packages/website/public/sitemap-index.xml | 5 + .../website/src/components/breadcrumbs.tsx | 32 +++ .../src/components/docs-breadcrumbs.tsx | 34 +-- .../website/src/components/landing-page.tsx | 17 +- .../website/src/components/site-footer.tsx | 9 + .../website/src/components/site-header.tsx | 9 + packages/website/src/meta.ts | 8 +- packages/website/src/plugins/author-link.tsx | 51 ++++ packages/website/src/plugins/categories.ts | 48 ++++ .../website/src/plugins/category-link.tsx | 36 +++ packages/website/src/plugins/index.ts | 2 + packages/website/src/plugins/launch.ts | 2 + packages/website/src/plugins/loader.ts | 14 + packages/website/src/plugins/not-found.tsx | 21 ++ packages/website/src/plugins/plugin-card.tsx | 49 ++++ .../src/plugins/plugin-page-header.tsx | 10 + packages/website/src/plugins/plugin-tile.tsx | 22 ++ packages/website/src/plugins/published.ts | 106 +++++++ packages/website/src/plugins/registry.test.ts | 84 ++++++ packages/website/src/plugins/registry.ts | 94 +++++++ packages/website/src/routeTree.gen.ts | 69 ++++- .../website/src/routes/plugins/$owner.tsx | 94 +++++++ .../src/routes/plugins/$owner_.$slug.tsx | 235 ++++++++++++++++ packages/website/src/routes/plugins/index.tsx | 261 ++++++++++++++++++ packages/website/src/server-entry.ts | 8 + packages/website/src/styles.css | 6 + packages/website/vite.config.ts | 6 + packages/website/wrangler.toml | 1 + public-docs/plugins/reference.md | 14 +- 48 files changed, 1998 insertions(+), 78 deletions(-) create mode 100644 packages/protocol/src/plugin-registry.ts create mode 100644 packages/server/src/server/plugins/managed-source/registry.ts create mode 100644 packages/website/e2e/plugins.spec.ts create mode 100644 packages/website/public/robots.txt create mode 100644 packages/website/public/sitemap-index.xml create mode 100644 packages/website/src/components/breadcrumbs.tsx create mode 100644 packages/website/src/plugins/author-link.tsx create mode 100644 packages/website/src/plugins/categories.ts create mode 100644 packages/website/src/plugins/category-link.tsx create mode 100644 packages/website/src/plugins/index.ts create mode 100644 packages/website/src/plugins/launch.ts create mode 100644 packages/website/src/plugins/loader.ts create mode 100644 packages/website/src/plugins/not-found.tsx create mode 100644 packages/website/src/plugins/plugin-card.tsx create mode 100644 packages/website/src/plugins/plugin-page-header.tsx create mode 100644 packages/website/src/plugins/plugin-tile.tsx create mode 100644 packages/website/src/plugins/published.ts create mode 100644 packages/website/src/plugins/registry.test.ts create mode 100644 packages/website/src/plugins/registry.ts create mode 100644 packages/website/src/routes/plugins/$owner.tsx create mode 100644 packages/website/src/routes/plugins/$owner_.$slug.tsx create mode 100644 packages/website/src/routes/plugins/index.tsx diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cd20556182..f99dabaea9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -7,6 +7,7 @@ on: - "CHANGELOG.md" - "public-docs/**" - "packages/website/**" + - "packages/protocol/**" - "package.json" - "package-lock.json" - "patches/**" @@ -29,7 +30,10 @@ jobs: cache: "npm" - name: Install dependencies - run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root + run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root + + - name: Build protocol + run: npm run build:protocol - name: Typecheck run: npm run typecheck --workspace=@getpaseo/website diff --git a/docs/plugins.md b/docs/plugins.md index ab12fe7dd2..e0d94de357 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -115,27 +115,37 @@ the bundled integration; an entry with `extends` shadows it with a custom provid [provider contributions](#contribute-a-provider) for the contract and [Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations. +## Install from a registry + +`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs +keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref +selection is unavailable for registry installs; install an explicit source to select your own. + +Use `host/owner/slug` for a registry at `https://host`, or set +`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). +Private registry credentials live in daemon config under +`pluginRegistries: { "host": { "authorization": "Bearer token" } }`. +Restart your daemon after changing these startup settings. Credentials go only to the registry, +never artifact hosts or redirects. Git/npm use their own host authentication. + +The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md) +owns static hosting, record shapes, pins, and advisory install counts. + ## Install a Git source -GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing -directory always wins over shorthand resolution. +GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory +still wins over source resolution. ```bash -paseo plugin add owner/repository -paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo:plugins/review -paseo plugin add owner/repository --ref main -paseo plugin ls -paseo plugin update review -paseo plugin update --all +paseo plugin install github:owner/repository +paseo plugin install https://gitlab.com/group/repository.git +paseo plugin install github:owner/monorepo:plugins/review +paseo plugin install github:owner/repository --ref main ``` -Append `:relative/path` to the source when the plugin lives below the repository root. - -`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's -current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote. -Removing a Git source deletes Paseo's managed checkout. +Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial +branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD +and ask for approval. `ls` reports the installed commit without contacting the remote. ## Managed source ownership diff --git a/package-lock.json b/package-lock.json index 6e8fb5385f..01d3bd7f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40165,7 +40165,8 @@ "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", - "wrangler": "^4.75.0" + "wrangler": "^4.75.0", + "@getpaseo/protocol": "*" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index ae3541eeb6..4250ba8620 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -218,7 +218,7 @@ export function createPluginCommand(): Command { .description("Trust and install a plugin from a directory, Git repository, or npm package") .argument( "", - "Host directory, Git or npm source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source", ) .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a4f883604e..47610620d7 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,3 +1,4 @@ +import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { AgentProfileSchema, @@ -1451,8 +1452,18 @@ export const PluginSourceInstallRequestSchema = z.object({ export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("directory"), path: z.string() }), - z.object({ kind: z.literal("git"), remote: z.string(), pluginPath: z.string() }), - z.object({ kind: z.literal("npm"), packageName: z.string(), pluginPath: z.string() }), + z.object({ + kind: z.literal("git"), + remote: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ + kind: z.literal("npm"), + packageName: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), ]); export const PluginInstallationSchema = z.object({ identity: PluginSourceIdentitySchema, diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts new file mode 100644 index 0000000000..36c5354c88 --- /dev/null +++ b/packages/protocol/src/plugin-registry.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const PluginRegistryIdSchema = z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/); +export const PluginRegistryIdentitySchema = z.object({ + url: z.string().url(), + id: PluginRegistryIdSchema, +}); +export const PluginRegistriesSchema = z.record( + z.string(), + z.object({ authorization: z.string().min(1) }), +); +export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("npm"), + package: z.string().regex(/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/), + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + resolved: z.string().url(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+=*$/), + }), + z.object({ + kind: z.literal("git"), + remote: z.string().min(1), + commit: z.string().regex(/^[0-9a-f]{40,64}$/), + pluginPath: z + .string() + .regex(/^[^\\:]+$/) + .optional(), + }), +]); +export const PublishedPluginSchema = z.object({ + id: PluginRegistryIdSchema, + name: z.string(), + description: z.string(), + license: z.string().optional(), + categories: z.array(z.string()), + author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }), + repository: z.object({ url: z.string().url(), commit: z.string().optional() }), + artifact: PluginRegistryArtifactSchema, + icon: z.string().url().optional(), + screenshots: z.array(z.string().url()), + submittedAt: z.string(), + reviewedAt: z.string(), + updatedAt: z.string(), + publishedAt: z.string(), + installs: z.number().int().nonnegative().optional(), +}); +export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); +export const PluginRegistryIndexSchema = z.object({ + schemaVersion: z.literal(1), + registry: z.object({ name: z.string(), url: z.string().url() }), + categories: z.array( + z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), + ), + plugins: z.array(PublishedPluginSchema), + generatedAt: z.string(), +}); +export type PluginRegistryIdentity = z.infer; +export type PluginRegistries = z.infer; + +export function parsePluginRegistryReference( + source: string, + defaultUrl = "https://plugins.paseo.sh", +): PluginRegistryIdentity | null { + const segments = source.split("/"); + if (segments.length === 2 && PluginRegistryIdSchema.safeParse(source).success) + return { url: defaultUrl.replace(/\/+$/, ""), id: source }; + const [host, ...rest] = segments; + if (segments.length !== 3 || !host || !/[.:]/.test(host) || /[@?#\\]/.test(host)) return null; + const id = rest.join("/"); + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + const url = new URL(`https://${host}`); + return { url: url.origin, id }; +} diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index 8d12f0b0c5..b8ce6e7346 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -1,3 +1,4 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; @@ -13,6 +14,7 @@ describe("plugin source references", () => { undefined, ], ["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"], + ["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined], ["owner/repository", "owner/repository", undefined], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ @@ -43,3 +45,19 @@ describe("plugin source references", () => { expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); }); }); + +it("addresses default and private registries without treating explicit sources as registry IDs", () => { + expect(parsePluginRegistryReference("omercnet/dracula")).toEqual({ + url: "https://plugins.paseo.sh", + id: "omercnet/dracula", + }); + expect(parsePluginRegistryReference("acme/plugin", "https://internal.example/registry/")).toEqual( + { url: "https://internal.example/registry", id: "acme/plugin" }, + ); + expect(parsePluginRegistryReference("internal.example:8443/acme/plugin")).toEqual({ + url: "https://internal.example:8443", + id: "acme/plugin", + }); + expect(parsePluginRegistryReference("github:acme/plugin")).toBeNull(); + expect(parsePluginRegistryReference("npm:@acme/plugin")).toBeNull(); +}); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index d944ae13b7..229dc1df88 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,3 +1,4 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { source: string; @@ -5,6 +6,7 @@ export interface PluginSourceReference { } export function parsePluginSourceReference(reference: string): PluginSourceReference { + if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined }; const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0]; const source = prefix ? reference.slice(prefix.length) : reference; const parsed = splitPluginPath(source, { scp: prefix !== "npm:" }); @@ -56,6 +58,7 @@ function isPortableRelativePluginPath(pluginPath: string): boolean { export function formatPluginIdentity(identity: PluginInstallation["identity"]): string { if (identity.kind === "directory") return identity.path; + if (identity.registry) return `${new URL(identity.registry.url).host}/${identity.registry.id}`; const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`; return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ebf6e0bc81..3c2428c2dc 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; @@ -411,6 +412,8 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -616,7 +619,10 @@ export async function createPaseoDaemon( const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { - managedSources: new ManagedPluginSources(config.paseoHome), + managedSources: new ManagedPluginSources(config.paseoHome, { + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 92bf9183ee..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -29,6 +29,7 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_COUNT", "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", + "PASEO_PLUGIN_REGISTRY", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 80045a4454..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,18 @@ describe("server config", () => { ).toBe(path.join(packageRoot, "app-dist")); }); }); + +test("loads private plugin registry settings through the configuration boundary", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); + try { + const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); + const config = loadConfig(home, { + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, + }); + expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); + expect(config.pluginRegistries).toEqual(pluginRegistries); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index a683ec69fb..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -626,6 +626,8 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, plugins: persisted.plugins, + pluginRegistries: persisted.pluginRegistries, + pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 40c4150907..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -306,6 +307,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginRegistries: PluginRegistriesSchema.optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 9d9bad15f3..7723721eb4 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -48,7 +48,7 @@ describe("managed Git plugin sources", () => { await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); }); - it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => { + it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); roots.push(home); @@ -61,11 +61,7 @@ describe("managed Git plugin sources", () => { Object.assign(process.env, overlay); try { const sources = new ManagedPluginSources(home); - for (const source of [ - "github:fixture/repository", - "fixture/repository", - "git:fixture/repository", - ]) { + for (const source of ["github:fixture/repository", "git:fixture/repository"]) { const candidate = await sources.prepareInstall({ source }); expect(candidate.record).toMatchObject({ kind: "git", @@ -275,3 +271,172 @@ describe("managed Git plugin sources", () => { } }, 30_000); }); + +describe("registry plugin sources", () => { + it("installs a bare id from a private registry and polls its pin without install intent", async () => { + const repository = await createRepository(); + await mkdir(path.join(repository, "packages/example"), { recursive: true }); + await rename( + path.join(repository, "paseo-plugin.json"), + path.join(repository, "packages/example/paseo-plugin.json"), + ); + await rename( + path.join(repository, "index.server.ts"), + path.join(repository, "packages/example/index.server.ts"), + ); + await commitAll(repository, "move plugin into monorepo"); + const revision = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const commit = revision.stdout.trim(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-home-")); + roots.push(home); + const requests: Array<{ + path: string | undefined; + intent: string | string[] | undefined; + authorization: string | undefined; + }> = []; + const server = createServer((req, res) => { + requests.push({ + path: req.url, + intent: req.headers["x-paseo-install"], + authorization: req.headers.authorization, + }); + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit, + pluginPath: "packages/example", + }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const host = `127.0.0.1:${address.port}`; + const url = `http://${host}/internal`; + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: url, + registries: { [host]: { authorization: "Bearer test" } }, + }); + let candidate = await sources.prepareInstall({ source: "acme/example" }); + candidate = await sources.place("managed-example", candidate); + expect( + JSON.parse(await readFile(path.join(candidate.directory, "paseo-plugin.json"), "utf8")).id, + ).toBe("managed-example"); + await sources.verifyCandidate("managed-example", candidate); + sources.commit("managed-example", candidate.record); + const preview = await sources.preview("managed-example", candidate.directory); + expect(preview.outcome).toBe("current"); + expect(preview.current?.identity).toEqual({ + kind: "git", + remote: pathToFileURL(repository).href, + pluginPath: "packages/example", + registry: { url, id: "acme/example" }, + }); + expect(requests).toEqual([ + { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + ]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + }); +}); + +it("installs and updates only the npm artifacts pinned by the plugin registry", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { resolveNpm } = await import("./managed-source/npm.js"); + const npm = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = npm.userconfig; + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-")); + roots.push(home); + const intents: Array = []; + let pin = await resolveNpm("paseo-fixture-plugin", "1.0.0", home); + const server = createServer((request, response) => { + intents.push(request.headers["x-paseo-install"]); + response.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Example", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; + try { + const sources = new ManagedPluginSources(home, options); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect((await sources.preview("example", candidate.directory)).outcome).toBe("current"); + pin = await resolveNpm("paseo-fixture-plugin", "1.1.0", home); + const restarted = new ManagedPluginSources(home, options); + const preview = await restarted.preview("example", candidate.directory); + expect(preview.target).toEqual({ kind: "npm", ...pin }); + expect(preview.outcome).toBe("update"); + const updated = await restarted.place( + "example", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + await restarted.verifyCandidate("example", updated); + expect(updated.record.registry).toEqual({ url: options.defaultUrl, id: "acme/example" }); + expect(intents).toEqual(["1", undefined, undefined, undefined]); + pin = { ...pin, integrity: "sha512-YWJj" }; + await expect(sources.prepareInstall({ source: "acme/example" })).rejects.toThrow( + "changed since review", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await npm.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index a2ab4a90f8..366d8d53b0 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -1,3 +1,8 @@ +import { + PluginRegistryIdentitySchema, + parsePluginRegistryReference, +} from "@getpaseo/protocol/plugin-registry"; +import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, realpath, rename, rm } from "node:fs/promises"; @@ -23,8 +28,12 @@ const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; // COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata. const ManagedPluginRecordSchema = z.union([ - z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }), - z.object({ kind: z.literal("npm") }), + z.object({ + kind: z.literal("git").default("git"), + remote: z.string().min(1), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ kind: z.literal("npm"), registry: PluginRegistryIdentitySchema.optional() }), ]); export type ManagedPluginRecord = z.infer; export interface ManagedPluginCandidate { @@ -33,6 +42,7 @@ export interface ManagedPluginCandidate { directory: string; record: ManagedPluginRecord; versionRoot: string; + target?: PluginUpdateTarget; } interface InstallInput { source: string; @@ -49,7 +59,10 @@ export class ManagedPluginSources { private readonly root: string; private readonly metadataPath: string; private readonly records: Record; - constructor(paseoHome: string) { + constructor( + paseoHome: string, + private readonly registryOptions: RegistryOptions = {}, + ) { this.root = path.resolve(paseoHome, "plugins"); this.metadataPath = path.join(this.root, "sources.json"); this.records = existsSync(this.metadataPath) @@ -85,6 +98,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "checkout"), identity: { kind: "git", + ...(record.registry ? { registry: record.registry } : {}), remote: redactRemoteCredentials(record.remote), pluginPath: parts.slice(2).join("/") || ".", }, @@ -100,6 +114,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "node_modules", packageName), identity: { kind: "npm", + ...(record.registry ? { registry: record.registry } : {}), packageName, pluginPath: parts.slice(2 + packageParts).join("/") || ".", }, @@ -132,6 +147,14 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + if (registry) { + if (input.ref || input.pluginPath) + throw new Error("Registry sources use their reviewed artifact path and revision"); + const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); + const candidate = await this.prepareInstall(resolved.input, resolved.target); + return { ...candidate, record: { ...candidate.record, registry } }; + } const pluginPath = normalizePluginPath(input.pluginPath); const versionRoot = await this.createStagingRoot(); try { @@ -163,7 +186,7 @@ export class ManagedPluginSources { assertPluginPath(sourceRoot, directory); await assertRealContainment(sourceRoot, directory); const { id: defaultId, build } = await readPluginManifest(directory); - return { build, defaultId, directory, record, versionRoot }; + return { build, defaultId, directory, record, versionRoot, ...(target ? { target } : {}) }; } catch (error) { await rm(versionRoot, { recursive: true, force: true }); throw error; @@ -202,7 +225,18 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.kind === "npm") { + if (current.identity.registry) { + if (selection) throw new Error("Registry updates use only the reviewed registry pin"); + const resolved = await resolveRegistryPlugin( + current.identity.registry, + this.registryOptions, + false, + ); + assertRegistryArtifactIdentity(current.identity, resolved.artifact); + target = resolved.target; + const revision = target.kind === "npm" ? target.version : target.commit; + outcome = revision === current.currentRevision ? "current" : "update"; + } else if (current.identity.kind === "npm") { target = { kind: "npm", ...(await resolveNpm( @@ -211,10 +245,7 @@ export class ManagedPluginSources { this.root, )), }; - const comparison = compareVersions(target.version, current.currentRevision); - if (comparison === 0) outcome = "current"; - else if (comparison < 0 && !selection) outcome = "installed-newer"; - else outcome = "update"; + outcome = npmUpdateOutcome(target.version, current.currentRevision, selection); } else { const record = this.records[pluginId]; if (record?.kind !== "git") throw new Error("Git source is no longer configured"); @@ -271,6 +302,14 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; + if (identity.kind !== "directory" && identity.registry) { + const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); + assertRegistryArtifactIdentity(identity, resolved.artifact); + if (!isDeepStrictEqual(resolved.target, proposal.target)) + throw new Error("Registry pin changed since review; check again"); + const candidate = await this.prepareInstall(resolved.input, proposal.target); + return { ...candidate, record: { ...candidate.record, registry: identity.registry } }; + } if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git") return this.prepareInstall( { @@ -296,6 +335,7 @@ export class ManagedPluginSources { candidate: ManagedPluginCandidate, target?: PluginUpdateTarget, ): Promise { + target ??= candidate.target; const location = this.locate(pluginId, candidate.directory, candidate.record); const revision = await this.revision(location, candidate.directory); if (target?.kind === "git" && revision !== target.commit) @@ -395,6 +435,7 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s return []; } function normalizeGitSource(source: string): string { + const explicit = source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { @@ -405,7 +446,7 @@ function normalizeGitSource(source: string): string { source = source.slice(4); } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github) { + if (github && explicit) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -578,3 +619,29 @@ async function revParse(cwd: string, ref: string): Promise { const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd }); return stdout.trim(); } + +function assertRegistryArtifactIdentity( + identity: Exclude, + artifact: Awaited>["artifact"], +): void { + const matches = + identity.kind === "npm" && artifact.kind === "npm" + ? identity.packageName === artifact.package && identity.pluginPath === "." + : identity.kind === "git" && + artifact.kind === "git" && + identity.remote === redactRemoteCredentials(artifact.remote) && + identity.pluginPath === (artifact.pluginPath ?? "."); + if (!matches) + throw new Error("Registry artifact source changed; reinstall to approve the new source"); +} + +function npmUpdateOutcome( + version: string, + current: string, + selection: PluginUpdateSelection | undefined, +): PluginUpdatePreview["outcome"] { + const comparison = compareVersions(version, current); + if (comparison === 0) return "current"; + if (comparison < 0 && !selection) return "installed-newer"; + return "update"; +} diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts new file mode 100644 index 0000000000..d57d0aea79 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,54 @@ +import { + PublishedPluginDetailSchema, + type PluginRegistries, + type PluginRegistryIdentity, +} from "@getpaseo/protocol/plugin-registry"; +import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; + +export interface RegistryOptions { + defaultUrl?: string; + registries?: PluginRegistries; +} +export async function resolveRegistryPlugin( + identity: PluginRegistryIdentity, + options: RegistryOptions, + install: boolean, +) { + const base = new URL(identity.url); + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password) + throw new Error("Registry base must be an HTTP(S) URL without credentials"); + const authorization = options.registries?.[base.host]?.authorization; + if ( + authorization && + base.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + ) + throw new Error("Registry credentials require HTTPS"); + const headers: Record = {}; + if (authorization) headers.Authorization = authorization; + if (install) headers["X-Paseo-Install"] = "1"; + const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) + throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); + const plugin = PublishedPluginDetailSchema.parse(await response.json()); + if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); + const artifact = plugin.artifact; + const target: PluginUpdateTarget = + artifact.kind === "npm" + ? { + kind: "npm", + version: artifact.version, + resolved: artifact.resolved, + integrity: artifact.integrity, + } + : { kind: "git", commit: artifact.commit }; + const input = + artifact.kind === "npm" + ? { source: `npm:${artifact.package}@${artifact.version}` } + : { source: `git:${artifact.remote}`, ref: artifact.commit, pluginPath: artifact.pluginPath }; + return { input, target, artifact }; +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..bd8f70f004 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,216 @@ +import { expect, test, type Page } from "playwright/test"; + +async function openPlugins(page: Page) { + // Wait for hydration so typing reaches React rather than the server-rendered input. + await page.goto("/plugins", { waitUntil: "networkidle" }); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +} + +test("browses from the directory into a category, a plugin, and its author", async ({ + page, + context, +}) => { + // WebKit has no clipboard permission to grant; record what the page writes instead. + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + (window as unknown as { __copied?: string }).__copied = text; + return Promise.resolve(); + }, + }, + }); + }); + await openPlugins(page); + + const themes = page.getByRole("region", { name: "Themes" }); + await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible(); + + await browseCategory(page, "Git & code review"); + await expect(page).toHaveURL(/category=/); + await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1); + await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0); + + await openPlugin(page, /Fresh Worktrees/); + await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); + await expect( + page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }), + ).toBeVisible(); + await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + ).toBeVisible(); + await copyInstallCommand(page); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) + .toBe("paseo plugin install omercnet/fresh-worktrees"); + + const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumbs).toContainText("Plugins"); + await expect(breadcrumbs).toContainText("Git & code review"); + await expect(breadcrumbs).toContainText("Fresh Worktrees"); + + await openAuthor(page, "Omer Cohen"); + await expect(page).toHaveURL(/\/plugins\/omercnet$/); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); +}); + +test("filters by search and clears to the full directory", async ({ page }) => { + await openPlugins(page); + + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/q=graphite/); + await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await searchPlugins(page, "zzzz-nothing"); + await expect(page.getByText("No plugins match.")).toBeVisible(); + await page.getByRole("link", { name: "Clear filters" }).click(); + await expect(page).toHaveURL(/\/plugins\/?$/); + await expect(page.getByRole("region", { name: "Themes" })).toBeVisible(); +}); + +test("explains a plugin that is not listed", async ({ page }) => { + await page.goto("/plugins/acme/does-not-exist"); + await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); + await page.getByRole("link", { name: "Browse all plugins" }).click(); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); + +async function copyInstallCommand(page: Page) { + await page.getByRole("button", { name: "Copy to clipboard" }).first().click(); +} + +async function browseCategory(page: Page, category: string) { + await page + .getByRole("region", { name: category }) + .getByRole("link", { name: /^View all/ }) + .click(); +} +async function openPlugin(page: Page, name: RegExp) { + await page.getByRole("link", { name }).first().click(); +} +async function openAuthor(page: Page, name: string) { + await page.getByRole("link", { name }).first().click(); +} +async function searchPlugins(page: Page, query: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).fill(query); +} + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + + await page.goto("/plugins/omercnet"); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Omer Cohen – Paseo plugins", "/plugins/omercnet"); + + const response = await page.goto("/plugins/omercnet/fresh-worktrees"); + expect(response?.status()).toBe(200); + expect(response?.headers()["cache-control"]).toBe("private, no-store"); + expect(response?.headers()["x-robots-tag"]).toBeUndefined(); + await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + ).toBeVisible(); + await expectPageMetadata( + page, + "Fresh Worktrees – Paseo plugin", + "/plugins/omercnet/fresh-worktrees", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + + await page.goto("/plugins/tomgrin10/graphite"); + await expectPageMetadata(page, "Graphite – Paseo plugin", "/plugins/tomgrin10/graphite"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://paseo.sh/og-image.png", + ); + }); + + test("returns real 404 pages for unknown plugins and authors", async ({ page }) => { + const plugin = await page.goto("/plugins/acme/does-not-exist"); + expect(plugin?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); + await expect(page).toHaveTitle("Plugin not found – Paseo"); + const author = await page.goto("/plugins/does-not-exist"); + expect(author?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); + await expect(page).toHaveTitle("Author not found – Paseo"); + }); + + test("discovers plugin and author URLs through robots and the sitemap index", async ({ + request, + }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain("Sitemap: https://paseo.sh/sitemap-index.xml"); + const index = await request.get("/sitemap-index.xml"); + expect(await index.text()).toContain("https://paseo.sh/sitemap-plugins.xml"); + const plugins = await request.get("/sitemap-plugins.xml"); + expect(plugins.status()).toBe(200); + expect(plugins.headers()["content-type"]).toContain("application/xml"); + expect(await plugins.text()).toContain("https://paseo.sh/plugins/omercnet"); + expect(await plugins.text()).toContain( + "https://paseo.sh/plugins/omercnet/fresh-worktrees", + ); + }); +}); + +async function expectPageMetadata(page: Page, title: string, path: string) { + await expect(page).toHaveTitle(title); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "website"); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + "summary_large_image", + ); + const description = await page.locator('meta[name="description"]').getAttribute("content"); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( + "content", + description!, + ); +} + +test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + "href", + "https://paseo.cafe", + ); + await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); + const response = await page.goto("/plugins"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); diff --git a/packages/website/package.json b/packages/website/package.json index 5f64191da9..a2159f2062 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -15,6 +15,7 @@ "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", "@fontsource-variable/geist": "^5.3.0", + "@getpaseo/protocol": "*", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "framer-motion": "^12.35.2", diff --git a/packages/website/public/robots.txt b/packages/website/public/robots.txt new file mode 100644 index 0000000000..75001ce745 --- /dev/null +++ b/packages/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://paseo.sh/sitemap-index.xml diff --git a/packages/website/public/sitemap-index.xml b/packages/website/public/sitemap-index.xml new file mode 100644 index 0000000000..ae15d8df3b --- /dev/null +++ b/packages/website/public/sitemap-index.xml @@ -0,0 +1,5 @@ + + + https://paseo.sh/sitemap.xml + https://paseo.sh/sitemap-plugins.xml + diff --git a/packages/website/src/components/breadcrumbs.tsx b/packages/website/src/components/breadcrumbs.tsx new file mode 100644 index 0000000000..240f34047b --- /dev/null +++ b/packages/website/src/components/breadcrumbs.tsx @@ -0,0 +1,32 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +export interface BreadcrumbItem { + label: string; + /** Internal path. The last item is the current page and has no href. */ + href?: string; +} + +export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) { + return ( + + ); +} diff --git a/packages/website/src/components/docs-breadcrumbs.tsx b/packages/website/src/components/docs-breadcrumbs.tsx index 7b82e7b4fb..0da4f4472a 100644 --- a/packages/website/src/components/docs-breadcrumbs.tsx +++ b/packages/website/src/components/docs-breadcrumbs.tsx @@ -1,5 +1,5 @@ -import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs"; interface DocsBreadcrumbsProps { @@ -8,27 +8,13 @@ interface DocsBreadcrumbsProps { } export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) { - const groups = getDocBreadcrumbGroups(doc, tree); - - return ( - + const items = useMemo( + () => [ + { label: "Docs", href: "/docs" }, + ...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })), + { label: doc.frontmatter.nav }, + ], + [doc, tree], ); + return ; } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index ce68595980..a1fcc18ef3 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,3 +1,4 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import * as React from "react"; import { ArrowRight, @@ -822,13 +823,15 @@ interface ExtensibleCardLink { const PLUGIN_CARD_LINKS: ReadonlyArray = [ { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, + PLUGINS_LINKED + ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } + : { + href: "https://paseo.cafe", + label: "Community plugins", + icon: Coffee, + external: true, + accent: true, + }, ]; const FORK_CARD_LINKS: ReadonlyArray = [ diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index ba092410da..42ee404279 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,3 +1,4 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; @@ -27,6 +28,14 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { > Docs + {PLUGINS_LINKED && ( + + Plugins + + )} Docs + {PLUGINS_LINKED && ( + + Plugins + + )} + + + ); + } + return ( + + {author.name.charAt(0).toUpperCase()} + + ); +} + +const DEFAULT_CLASS = + "inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"; + +/** Link to the author's plugin list. Renders avatar and name unless children are given. */ +export function AuthorLink({ + author, + className = DEFAULT_CLASS, + children, +}: { + author: Author; + className?: string; + children?: ReactNode; +}) { + const params = useMemo(() => ({ owner: author.username }), [author.username]); + return ( + + {children ?? ( + <> + + {author.name} + + )} + + ); +} diff --git a/packages/website/src/plugins/categories.ts b/packages/website/src/plugins/categories.ts new file mode 100644 index 0000000000..498bea280e --- /dev/null +++ b/packages/website/src/plugins/categories.ts @@ -0,0 +1,48 @@ +export const CATEGORIES = [ + { slug: "themes", label: "Themes", description: "Change how Paseo looks." }, + { + slug: "agents-and-providers", + label: "Agents & providers", + description: "Run more coding agents through Paseo.", + }, + { + slug: "monitoring", + label: "Monitoring & orchestration", + description: "See every agent at once and steer work across workspaces.", + }, + { + slug: "timeline-and-composer", + label: "Timeline & composer", + description: "Change what a conversation shows and how you write to agents.", + }, + { + slug: "workspace-panels", + label: "Workspace panels", + description: "Tools that open as tabs next to your agents.", + }, + { + slug: "git-and-code-review", + label: "Git & code review", + description: "Branches, worktrees, and pull requests.", + }, + { + slug: "automation", + label: "Automation & scheduling", + description: "Run things later, on a schedule, or when an agent goes idle.", + }, + { slug: "notifications", label: "Notifications", description: "Know when an agent needs you." }, + { + slug: "usage-and-pricing", + label: "Usage & pricing", + description: "What models cost and how much you have used.", + }, + { + slug: "integrations", + label: "Integrations", + description: "Issues, tasks, and tools from other services inside Paseo.", + }, + { slug: "utilities", label: "Utilities", description: "Small helpers for everyday work." }, +] as const; + +export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; +export type Category = (typeof CATEGORIES)[number]; diff --git a/packages/website/src/plugins/category-link.tsx b/packages/website/src/plugins/category-link.tsx new file mode 100644 index 0000000000..d51dbebd25 --- /dev/null +++ b/packages/website/src/plugins/category-link.tsx @@ -0,0 +1,36 @@ +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useMemo } from "react"; +import type { CategorySlug, PluginSort } from "./registry"; + +interface CategoryLinkProps { + /** `null` links to the unfiltered list. */ + category: CategorySlug | null; + q?: string; + sort?: PluginSort; + replace?: boolean; + className?: string; + children: ReactNode; +} + +/** Link into the plugin list filtered by category, keeping any search and sort. */ +export function CategoryLink({ + category, + q, + sort, + replace, + className, + children, +}: CategoryLinkProps) { + const search = useMemo(() => { + const next: { category?: CategorySlug; q?: string; sort?: PluginSort } = {}; + if (category) next.category = category; + if (q) next.q = q; + if (sort === "new") next.sort = sort; + return next; + }, [category, q, sort]); + return ( + + {children} + + ); +} diff --git a/packages/website/src/plugins/index.ts b/packages/website/src/plugins/index.ts new file mode 100644 index 0000000000..d0c892ed8c --- /dev/null +++ b/packages/website/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from "./registry"; +export { getRegistry, getRegistryPlugin } from "./loader"; diff --git a/packages/website/src/plugins/launch.ts b/packages/website/src/plugins/launch.ts new file mode 100644 index 0000000000..bc784696de --- /dev/null +++ b/packages/website/src/plugins/launch.ts @@ -0,0 +1,2 @@ +// Keep navigation on paseo.cafe until the coordinated directory announcement. +export const PLUGINS_LINKED = false; diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts new file mode 100644 index 0000000000..617cd5e9ed --- /dev/null +++ b/packages/website/src/plugins/loader.ts @@ -0,0 +1,14 @@ +import { createServerFn } from "@tanstack/react-start"; +import { env } from "cloudflare:workers"; +import { getWebsiteCacheContext } from "~/cloudflare-cache"; +import { loadRegistryIndex, loadRegistryPlugin, type RegistryEnvironment } from "./published"; + +function registryBase(): string { + return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; +} +export const getRegistry = createServerFn({ method: "GET" }).handler(() => + loadRegistryIndex(registryBase(), getWebsiteCacheContext()), +); +export const getRegistryPlugin = createServerFn({ method: "GET" }) + .validator((id: string) => id) + .handler(({ data }) => loadRegistryPlugin(registryBase(), data, getWebsiteCacheContext())); diff --git a/packages/website/src/plugins/not-found.tsx b/packages/website/src/plugins/not-found.tsx new file mode 100644 index 0000000000..f07a363b12 --- /dev/null +++ b/packages/website/src/plugins/not-found.tsx @@ -0,0 +1,21 @@ +import { Link } from "@tanstack/react-router"; +import { Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; + +const NOT_FOUND_CRUMBS = [{ label: "Plugins", href: "/plugins" }, { label: "Not found" }]; + +export function PluginsNotFound({ title, children }: { title: string; children: string }) { + return ( + + +

{title}

+

+ {children}{" "} + + Browse all plugins + + . +

+
+ ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx new file mode 100644 index 0000000000..6c3502f78c --- /dev/null +++ b/packages/website/src/plugins/plugin-card.tsx @@ -0,0 +1,49 @@ +import { Link } from "@tanstack/react-router"; +import { Download } from "lucide-react"; +import { useMemo } from "react"; +import { PluginTile } from "./plugin-tile"; +import { formatInstalls, getAuthor, getCategory, type Plugin } from "./registry"; + +export const PLUGIN_GRID_CLASS = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3"; + +export function PluginCard({ plugin }: { plugin: Plugin }) { + const author = getAuthor(plugin); + const category = getCategory(plugin.categories[0]); + const params = useMemo(() => { + const [owner, slug] = plugin.id.split("/"); + return { owner, slug }; + }, [plugin.id]); + return ( + +
+ +
+

{plugin.name}

+ {author &&

{author.name}

} +
+
+

+ {plugin.description} +

+
+ {category ? ( + + {category.label} + + ) : ( + + )} + {plugin.installs !== undefined && ( + + + {formatInstalls(plugin.installs)} installs + + )} +
+ + ); +} diff --git a/packages/website/src/plugins/plugin-page-header.tsx b/packages/website/src/plugins/plugin-page-header.tsx new file mode 100644 index 0000000000..9547d2fdda --- /dev/null +++ b/packages/website/src/plugins/plugin-page-header.tsx @@ -0,0 +1,10 @@ +import { ShieldAlert } from "lucide-react"; + +export function PluginsTrustNote() { + return ( +

+ + Plugins run unsandboxed on your machine. Read the source before you install. +

+ ); +} diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx new file mode 100644 index 0000000000..01234ac8b8 --- /dev/null +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -0,0 +1,22 @@ +import type { Plugin } from "./registry"; + +const SIZE_CLASS = { + sm: "h-9 w-9 rounded-lg text-sm", + lg: "h-14 w-14 rounded-xl text-xl", +} as const; + +/** PNG icon with a letter tile when no icon is published. */ +export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeof SIZE_CLASS }) { + return ( +
+ {plugin.icon ? ( + + ) : ( + plugin.name.charAt(0).toUpperCase() + )} +
+ ); +} diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts new file mode 100644 index 0000000000..9d8697aabb --- /dev/null +++ b/packages/website/src/plugins/published.ts @@ -0,0 +1,106 @@ +import { + PluginRegistryIdSchema, + PluginRegistryIndexSchema, + PublishedPluginDetailSchema, +} from "@getpaseo/protocol/plugin-registry"; +import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +export interface RegistryEnvironment { + PLUGINS_REGISTRY_URL?: string; + WEBSITE_CACHE?: KVNamespace; +} +async function documentAt(base: string, path: string): Promise { + const response = await fetch(`${base.replace(/\/+$/, "")}/${path}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Plugin registry returned ${response.status}`); + return response.json(); +} +export function loadRegistryIndex(base: string, context: WebsiteCacheContext) { + return getBlockingColdCache({ + context, + key: `plugins:index:v1:${base}`, + isValue: (value): value is ReturnType => + PluginRegistryIndexSchema.safeParse(value).success, + fetchFresh: async () => PluginRegistryIndexSchema.parse(await documentAt(base, "index.json")), + }); +} +export async function loadRegistryPlugin(base: string, id: string, context: WebsiteCacheContext) { + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + return getBlockingColdCache({ + context, + key: `plugins:detail:v1:${base}:${id}`, + isValue: (value): value is ReturnType | null => + value === null || PublishedPluginDetailSchema.safeParse(value).success, + fetchFresh: async () => { + const raw = await documentAt(base, `plugins/${id}.json`); + if (raw === null) return null; + const plugin = PublishedPluginDetailSchema.parse(raw); + if (plugin.id !== id) throw new Error("Plugin registry returned a different ID"); + return plugin; + }, + }); +} +export async function handlePluginRegistryRequest( + request: Request, + env: RegistryEnvironment, + context: WebsiteCacheContext, +): Promise { + const url = new URL(request.url); + if (request.method !== "GET") return null; + const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; + if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json") + return Response.json(await loadRegistryIndex(base, context)); + if (url.pathname === "/sitemap-plugins.xml") { + const index = await loadRegistryIndex(base, context); + // IDs are validated owner/slug strings, so these paths contain no XML metacharacters. + const paths = new Set(["/plugins"]); + for (const plugin of index.plugins) { + paths.add(`/plugins/${plugin.id.split("/")[0]}`); + paths.add(`/plugins/${plugin.id}`); + } + const urls = [...paths].sort().map((path) => `https://paseo.sh${path}`); + return new Response( + `${urls.join("")}`, + { + headers: { + "content-type": "application/xml; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }, + ); + } + if (url.pathname === "/api/plugins/installs") { + const index = await loadRegistryIndex(base, context); + const entries = await Promise.all( + index.plugins.map( + async (plugin) => + [ + plugin.id, + Number((await context.cache?.get(`plugin-installs:${plugin.id}`)) ?? 0), + ] as const, + ), + ); + return Response.json(Object.fromEntries(entries)); + } + const match = + /^\/api\/plugins\/resolve\/([^/]+\/[^/]+)$/.exec(url.pathname) ?? + /^\/plugins\/([^/]+\/[^/]+)\.json$/.exec(url.pathname); + if (!match) return null; + const id = match[1]; + const plugin = await loadRegistryPlugin(base, id, context); + if (!plugin) return Response.json({ error: "Plugin not found" }, { status: 404 }); + if ( + request.headers.get("X-Paseo-Install") === "1" || + url.searchParams.get("intent") === "install" + ) { + const cache = context.cache; + if (cache) + context.waitUntil( + (async () => { + const key = `plugin-installs:${id}`; + const count = Number((await cache.get(key)) ?? 0); + await cache.put(key, String(count + 1)); + })().catch(() => undefined), + ); + } + return Response.json(plugin); +} diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts new file mode 100644 index 0000000000..20f5cfda6e --- /dev/null +++ b/packages/website/src/plugins/registry.test.ts @@ -0,0 +1,84 @@ +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { + formatInstalls, + installCommand, + pinnedInstallCommand, + queryPlugins, + type Plugin, +} from "./registry"; +import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +const plugin: Plugin = { + id: "acme/example", + name: "Example", + description: "An example", + categories: ["themes"], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "npm", + package: "paseo-example", + version: "1.2.3", + resolved: "https://registry.npmjs.org/example.tgz", + integrity: "sha512-YWJj", + }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; +describe("plugin registry", () => { + it("loads a directory and a detail from a static HTTP registry", async () => { + const index = { + schemaVersion: 1, + registry: { name: "Internal", url: "https://example.test" }, + categories: [], + plugins: [plugin], + generatedAt: "2026-10-03", + }; + const detail = { ...plugin, readme: "# Example" }; + const server = createServer((request, response) => { + const value = request.url === "/index.json" ? index : detail; + response.end(JSON.stringify(value)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + const base = `http://127.0.0.1:${address.port}`; + const context = { cache: null, waitUntil: () => undefined }; + try { + expect(await loadRegistryIndex(base, context)).toEqual(index); + expect(await loadRegistryPlugin(base, plugin.id, context)).toEqual(detail); + await expect(loadRegistryPlugin(base, "acme/wrong", context)).rejects.toThrow("different ID"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("filters namespaced IDs and ranks by installs", () => { + const popular = { ...plugin, id: "acme/popular", installs: 100 }; + expect(queryPlugins([plugin, popular], { sort: "popular" }).map((p) => p.id)).toEqual([ + "acme/popular", + "acme/example", + ]); + expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]); + expect(queryPlugins([plugin], { category: "utilities", sort: "popular" })).toEqual([]); + }); + it("offers a registry command and an exact explicit command", () => { + expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); + expect(pinnedInstallCommand(plugin)).toBe("paseo plugin install npm:paseo-example@1.2.3"); + expect( + pinnedInstallCommand({ + ...plugin, + artifact: { + kind: "git", + remote: "https://github.com/acme/plugins.git", + commit: "a".repeat(40), + pluginPath: "packages/example", + }, + }), + ).toBe(`paseo plugin install github:acme/plugins:packages/example --ref ${"a".repeat(40)}`); + expect(formatInstalls(1250)).toBe("1.3k"); + }); +}); diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts new file mode 100644 index 0000000000..33f822cd3f --- /dev/null +++ b/packages/website/src/plugins/registry.ts @@ -0,0 +1,94 @@ +import type { z } from "zod"; +import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { CATEGORIES, type Category, type CategorySlug } from "./categories"; +export { CATEGORIES, type Category, type CategorySlug }; +export type Plugin = z.infer; +export interface Author { + username: string; + name: string; + github: string; + npm?: string; +} +export type PluginSort = "popular" | "new"; +export function getCategory(slug: string): Category | null { + return CATEGORIES.find((category) => category.slug === slug) ?? null; +} +export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] { + return plugins.filter((plugin) => plugin.categories.includes(slug)); +} +/** The namespace segment of the plugin ID. It is the route param for the plugin and its author. */ +export function pluginOwner(plugin: Plugin): string { + return plugin.id.split("/")[0]; +} +export function getAuthor(plugin: Plugin): Author { + const { author } = plugin; + return { + username: pluginOwner(plugin), + github: author.github, + name: author.name ?? author.github, + npm: author.npm, + }; +} +export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { + return plugins.filter((plugin) => pluginOwner(plugin) === owner); +} +export function installCommand(plugin: Plugin): string { + return `paseo plugin install ${plugin.id}`; +} +export function pinnedInstallCommand(plugin: Plugin): string { + const artifact = plugin.artifact; + if (artifact.kind === "npm") + return `paseo plugin install npm:${artifact.package}@${artifact.version}`; + const source = artifact.remote + .replace(/^https:\/\/github.com\//, "github:") + .replace(/\.git$/, ""); + const location = artifact.pluginPath ? `${source}:${artifact.pluginPath}` : source; + return `paseo plugin install ${location} --ref ${artifact.commit}`; +} +export function pluginVersion(plugin: Plugin): string { + return plugin.artifact.kind === "npm" + ? plugin.artifact.version + : plugin.artifact.commit.slice(0, 12); +} +export function npmUrl(plugin: Plugin): string | undefined { + return plugin.artifact.kind === "npm" + ? `https://www.npmjs.com/package/${plugin.artifact.package}` + : undefined; +} +export function authorNpmUrl(author: Author): string | undefined { + return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined; +} +export function authorGitHubUrl(author: Author): string { + return `https://github.com/${author.github}`; +} +export function authorAvatarUrl(author: Author): string { + return `https://github.com/${author.github}.png?size=80`; +} +export function formatInstalls(count: number): string { + if (count < 1000) return String(count); + const k = count / 1000; + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; +} +export interface PluginQuery { + category?: CategorySlug; + q?: string; + sort: PluginSort; +} +export function queryPlugins(plugins: Plugin[], query: PluginQuery): Plugin[] { + const needle = query.q?.trim().toLowerCase() ?? ""; + const matches = plugins.filter( + (plugin) => + (!query.category || plugin.categories.includes(query.category)) && + (!needle || + `${plugin.name} ${plugin.description} ${plugin.id}`.toLowerCase().includes(needle)), + ); + return sortPlugins(matches, query.sort); +} +export function sortPlugins(list: Plugin[], sort: PluginSort): Plugin[] { + return [...list].sort( + (a, b) => + (sort === "new" + ? b.updatedAt.localeCompare(a.updatedAt) + : (b.installs ?? 0) - (a.installs ?? 0)) || a.name.localeCompare(b.name), + ); +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 9b2a842b0d..a9b668c187 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -60,8 +60,10 @@ import { Route as AmpRouteImport } from "./routes/amp"; import { Route as AgoragenticRouteImport } from "./routes/agoragentic"; import { Route as AgentsRouteImport } from "./routes/agents"; import { Route as IndexRouteImport } from "./routes/index"; +import { Route as PluginsIndexRouteImport } from "./routes/plugins/index"; import { Route as DocsIndexRouteImport } from "./routes/docs/index"; import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as PluginsOwnerRouteImport } from "./routes/plugins/$owner"; import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks"; import { Route as DocsSplatRouteImport } from "./routes/docs/$"; import { Route as BlogSplatRouteImport } from "./routes/blog/$"; @@ -73,6 +75,7 @@ import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternative import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor"; import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app"; import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop"; +import { Route as PluginsOwnerSlugRouteImport } from "./routes/plugins/$owner_.$slug"; const VtcodeRoute = VtcodeRouteImport.update({ id: "/vtcode", @@ -329,6 +332,11 @@ const IndexRoute = IndexRouteImport.update({ path: "/", getParentRoute: () => rootRouteImport, } as any); +const PluginsIndexRoute = PluginsIndexRouteImport.update({ + id: "/plugins/", + path: "/plugins/", + getParentRoute: () => rootRouteImport, +} as any); const DocsIndexRoute = DocsIndexRouteImport.update({ id: "/", path: "/", @@ -339,6 +347,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: "/", getParentRoute: () => BlogRoute, } as any); +const PluginsOwnerRoute = PluginsOwnerRouteImport.update({ + id: "/plugins/$owner", + path: "/plugins/$owner", + getParentRoute: () => rootRouteImport, +} as any); const DownloadThanksRoute = DownloadThanksRouteImport.update({ id: "/download_/thanks", path: "/download/thanks", @@ -396,6 +409,11 @@ const AlternativesClaudeDesktopRoute = path: "/alternatives/claude-desktop", getParentRoute: () => rootRouteImport, } as any); +const PluginsOwnerSlugRoute = PluginsOwnerSlugRouteImport.update({ + id: "/plugins/$owner_/$slug", + path: "/plugins/$owner/$slug", + getParentRoute: () => rootRouteImport, +} as any); export interface FileRoutesByFullPath { "/": typeof IndexRoute; @@ -460,8 +478,11 @@ export interface FileRoutesByFullPath { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; @@ -524,8 +545,11 @@ export interface FileRoutesByTo { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog": typeof BlogIndexRoute; "/docs": typeof DocsIndexRoute; + "/plugins": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; @@ -591,8 +615,11 @@ export interface FileRoutesById { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download_/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner_/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; @@ -659,8 +686,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner/$slug"; fileRoutesByTo: FileRoutesByTo; to: | "/" @@ -723,8 +753,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" | "/blog" - | "/docs"; + | "/docs" + | "/plugins" + | "/plugins/$owner/$slug"; id: | "__root__" | "/" @@ -789,8 +822,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download_/thanks" + | "/plugins/$owner" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner_/$slug"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { @@ -854,6 +890,9 @@ export interface RootRouteChildren { AlternativesOrcaRoute: typeof AlternativesOrcaRoute; AlternativesSupersetRoute: typeof AlternativesSupersetRoute; DownloadThanksRoute: typeof DownloadThanksRoute; + PluginsOwnerRoute: typeof PluginsOwnerRoute; + PluginsIndexRoute: typeof PluginsIndexRoute; + PluginsOwnerSlugRoute: typeof PluginsOwnerSlugRoute; } declare module "@tanstack/react-router" { @@ -1215,6 +1254,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof IndexRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/": { + id: "/plugins/"; + path: "/plugins"; + fullPath: "/plugins/"; + preLoaderRoute: typeof PluginsIndexRouteImport; + parentRoute: typeof rootRouteImport; + }; "/docs/": { id: "/docs/"; path: "/"; @@ -1229,6 +1275,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BlogIndexRouteImport; parentRoute: typeof BlogRoute; }; + "/plugins/$owner": { + id: "/plugins/$owner"; + path: "/plugins/$owner"; + fullPath: "/plugins/$owner"; + preLoaderRoute: typeof PluginsOwnerRouteImport; + parentRoute: typeof rootRouteImport; + }; "/download_/thanks": { id: "/download_/thanks"; path: "/download/thanks"; @@ -1306,6 +1359,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/$owner_/$slug": { + id: "/plugins/$owner_/$slug"; + path: "/plugins/$owner/$slug"; + fullPath: "/plugins/$owner/$slug"; + preLoaderRoute: typeof PluginsOwnerSlugRouteImport; + parentRoute: typeof rootRouteImport; + }; } } @@ -1394,6 +1454,9 @@ const rootRouteChildren: RootRouteChildren = { AlternativesOrcaRoute: AlternativesOrcaRoute, AlternativesSupersetRoute: AlternativesSupersetRoute, DownloadThanksRoute: DownloadThanksRoute, + PluginsOwnerRoute: PluginsOwnerRoute, + PluginsIndexRoute: PluginsIndexRoute, + PluginsOwnerSlugRoute: PluginsOwnerSlugRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx new file mode 100644 index 0000000000..562d1d91cc --- /dev/null +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -0,0 +1,94 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { ExternalLink } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + authorGitHubUrl, + authorNpmUrl, + getAuthor, + getRegistry, + getPluginsByAuthor, + pluginOwner, + sortPlugins, +} from "~/plugins"; +import { AuthorAvatar } from "~/plugins/author-link"; +import { PluginsNotFound } from "~/plugins/not-found"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/$owner")({ + loader: async ({ params }) => { + const registry = await getRegistry(); + const first = registry.plugins.find((plugin) => pluginOwner(plugin) === params.owner); + if (!first) throw notFound(); + return { plugins: registry.plugins, author: getAuthor(first) }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData ? `${loaderData.author.name} – Paseo plugins` : "Author not found – Paseo", + loaderData ? `Paseo plugins published by ${loaderData.author.name}.` : "Author not found.", + `/plugins/${params.owner}`, + ), + component: AuthorPage, + notFoundComponent: () => ( + + Nobody with that GitHub owner has a plugin listed. + + ), +}); + +const LINK_CLASS = + "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +function AuthorPage() { + const { plugins: allPlugins, author } = Route.useLoaderData(); + const crumbs = useMemo( + () => [{ label: "Plugins", href: "/plugins" }, { label: author.name }], + [author], + ); + + const plugins = sortPlugins(getPluginsByAuthor(allPlugins, author.username), "popular"); + const github = authorGitHubUrl(author); + + return ( + + +
+ +
+ {plugins.map((plugin) => ( + + ))} +
+ + ); +} diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx new file mode 100644 index 0000000000..ec14392db2 --- /dev/null +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -0,0 +1,235 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { Download, ExternalLink } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { CodeBlock } from "~/components/code-block"; +import { DocsMarkdown } from "~/components/docs-markdown"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + formatInstalls, + getAuthor, + getCategory, + getRegistry, + getRegistryPlugin, + pinnedInstallCommand, + pluginVersion, + getPluginsByAuthor, + getPluginsInCategory, + installCommand, + npmUrl, + type Plugin, + sortPlugins, +} from "~/plugins"; +import { AuthorLink } from "~/plugins/author-link"; +import { CategoryLink } from "~/plugins/category-link"; +import { PluginsNotFound } from "~/plugins/not-found"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import { PluginsTrustNote } from "~/plugins/plugin-page-header"; +import { PluginTile } from "~/plugins/plugin-tile"; +import "~/styles.css"; + +const RELATED_COUNT = 3; + +export const Route = createFileRoute("/plugins/$owner_/$slug")({ + loader: async ({ params }) => { + const [registry, plugin] = await Promise.all([ + getRegistry(), + getRegistryPlugin({ data: `${params.owner}/${params.slug}` }), + ]); + if (!plugin) throw notFound(); + return { plugins: registry.plugins, plugin }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", + loaderData?.plugin?.description ?? "Plugin not found.", + `/plugins/${params.owner}/${params.slug}`, + loaderData?.plugin.screenshots[0], + ), + component: PluginPage, + notFoundComponent: () => ( + + There is no plugin listed at this address. + + ), +}); + +const LINK_CLASS = + "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const VIEW_ALL_CLASS = + "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const CATEGORY_PILL_CLASS = + "rounded-full border border-white/10 px-2 py-1 text-xs text-white/40 transition-colors hover:border-white/20 hover:text-white/70"; + +function PluginPage() { + const { plugins, plugin } = Route.useLoaderData(); + const primaryCategory = getCategory(plugin.categories[0]); + + const crumbs = useMemo(() => { + return [ + { label: "Plugins", href: "/plugins" }, + ...(primaryCategory + ? [{ label: primaryCategory.label, href: `/plugins?category=${primaryCategory.slug}` }] + : []), + { label: plugin.name }, + ]; + }, [plugin, primaryCategory]); + + const author = getAuthor(plugin); + const count = plugin.installs; + const readme = plugin.readme; + + return ( + + + +
+
+ +
+

{plugin.name}

+

{plugin.description}

+
+ {author && } + {count !== undefined && ( + + + {formatInstalls(count)} + installs + + )} + {plugin.categories.map((slug) => { + const category = getCategory(slug); + if (!category) return null; + return ( + + {category.label} + + ); + })} +
+
+
+
+ {installCommand(plugin)} +

+ {pluginVersion(plugin)} · Updated {plugin.updatedAt.slice(0, 10)} +

+

For older daemons

+ {pinnedInstallCommand(plugin)} +
+ +
+ + Source + + + {npmUrl(plugin) && ( + + npm + + + )} +
+
+
+
+ + {plugin.screenshots.length > 0 && ( +
+ {plugin.screenshots.map((url, index) => ( + + {`${plugin.name} + + ))} +
+ )} + +
+ {readme} +
+ + +
+ ); +} + +function RelatedPlugins({ plugin, plugins }: { plugin: Plugin; plugins: Plugin[] }) { + const category = getCategory(plugin.categories[0]); + const author = getAuthor(plugin); + const inCategory = category + ? sortPlugins( + getPluginsInCategory(plugins, category.slug).filter( + (candidate) => candidate.id !== plugin.id, + ), + "popular", + ).slice(0, RELATED_COUNT) + : []; + const byAuthor = author + ? sortPlugins( + getPluginsByAuthor(plugins, author.username).filter( + (candidate) => candidate.id !== plugin.id && !inCategory.includes(candidate), + ), + "popular", + ).slice(0, RELATED_COUNT) + : []; + + if (inCategory.length === 0 && byAuthor.length === 0) return null; + + return ( +
+ {category && inCategory.length > 0 && ( +
+
+

More in {category.label}

+ + View all + +
+
+ {inCategory.map((candidate) => ( + + ))} +
+
+ )} + {author && byAuthor.length > 0 && ( +
+
+

More from {author.name}

+ + View all + +
+
+ {byAuthor.map((candidate) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx new file mode 100644 index 0000000000..0c624c2749 --- /dev/null +++ b/packages/website/src/routes/plugins/index.tsx @@ -0,0 +1,261 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { type ChangeEvent, useCallback } from "react"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + CATEGORIES, + type CategorySlug, + getCategory, + getRegistry, + getPluginsInCategory, + type Plugin, + type PluginSort, + queryPlugins, + sortPlugins, +} from "~/plugins"; +import { CategoryLink } from "~/plugins/category-link"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import { PluginsTrustNote } from "~/plugins/plugin-page-header"; +import "~/styles.css"; + +interface PluginsSearch { + category?: CategorySlug; + q?: string; + sort?: PluginSort; +} + +const SECTION_PREVIEW_COUNT = 3; + +export const Route = createFileRoute("/plugins/")({ + validateSearch: (search: Record): PluginsSearch => { + const result: PluginsSearch = {}; + const category = typeof search.category === "string" ? getCategory(search.category) : null; + if (category) result.category = category.slug; + if (typeof search.q === "string" && search.q.trim()) result.q = search.q; + if (search.sort === "new") result.sort = "new"; + return result; + }, + head: () => + pageMeta( + "Plugins – Extend Paseo with community plugins", + "Themes, providers, panels, and automations built by the Paseo community. Install any of them with one command.", + "/plugins", + ), + loader: () => getRegistry(), + component: PluginsPage, +}); + +const PILL_BASE = + "inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors"; +const PILL_ACTIVE = `${PILL_BASE} border-white/20 bg-white/[0.07] text-white`; +const PILL_INACTIVE = `${PILL_BASE} border-white/10 bg-white/[0.025] text-muted-foreground hover:border-white/15 hover:bg-white/[0.04] hover:text-foreground`; +const VIEW_ALL_CLASS = + "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +function PluginsPage() { + const search = Route.useSearch(); + const { plugins } = Route.useLoaderData(); + const navigate = useNavigate({ from: "/plugins/" }); + const sort: PluginSort = search.sort ?? "popular"; + const filtering = Boolean(search.category || search.q); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + const q = event.target.value; + void navigate({ + search: (prev) => (q ? { ...prev, q } : { ...prev, q: undefined }), + replace: true, + }); + }, + [navigate], + ); + const handleSort = useCallback( + (next: PluginSort) => { + void navigate({ + search: (prev) => ({ ...prev, sort: next === "new" ? next : undefined }), + replace: true, + }); + }, + [navigate], + ); + + return ( + +

Plugins

+

+ Themes, providers, panels, and automations built by the Paseo community. Install any of them + with one command. +

+ + +
+ +
+ + Popular + + + New + +
+
+ +
+ + All + + {CATEGORIES.map((category) => { + if (getPluginsInCategory(plugins, category.slug).length === 0) return null; + return ( + + {category.label} + + ); + })} +
+ + {filtering ? ( + + ) : ( + + )} +
+ ); +} + +function SortButton({ + value, + current, + onSelect, + children, +}: { + value: PluginSort; + current: PluginSort; + onSelect: (sort: PluginSort) => void; + children: string; +}) { + const handleClick = useCallback(() => onSelect(value), [onSelect, value]); + const active = value === current; + return ( + + ); +} + +function CategorySections({ sort, plugins }: { sort: PluginSort; plugins: Plugin[] }) { + return ( +
+ {CATEGORIES.map((category) => { + const all = sortPlugins(getPluginsInCategory(plugins, category.slug), sort); + if (all.length === 0) return null; + const shown = all.slice(0, SECTION_PREVIEW_COUNT); + return ( +
+
+
+

+ {category.label} +

+

{category.description}

+
+ {all.length > shown.length && ( + + View all ({all.length}) + + )} +
+
+ {shown.map((plugin) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +function FilteredPlugins({ + search, + sort, + plugins, +}: { + search: PluginsSearch; + sort: PluginSort; + plugins: Plugin[]; +}) { + const category = search.category ? getCategory(search.category) : null; + const results = queryPlugins(plugins, { category: search.category, q: search.q, sort }); + const total = plugins.length; + + return ( +
+
+

+ {category ? category.label : `Results for “${search.q}”`} +

+

+ {category && !search.q ? category.description : null} + {search.q && category ? `Matching “${search.q}”.` : null} + {!category && search.q ? `${results.length} of ${total} plugins.` : null} +

+
+ {results.length === 0 ? ( +
+

No plugins match.

+ + Clear filters + +
+ ) : ( +
+ {results.map((plugin) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 657abf9cfe..775e8348ea 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,3 +1,4 @@ +import { handlePluginRegistryRequest } from "~/plugins/published"; import type { Register } from "@tanstack/react-router"; import type { RequestHandler } from "@tanstack/react-start/server"; import { getAndroidVersionCode } from "~/android-version"; @@ -8,6 +9,7 @@ import { buildLlmsTxt } from "~/llms"; interface WebsiteEnv { WEBSITE_CACHE?: KVNamespace; + PLUGINS_REGISTRY_URL?: string; } function markdownResponse(body: string): Response { @@ -79,6 +81,12 @@ export default { async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); + const pluginResponse = await handlePluginRegistryRequest(request, env, { + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + if (pluginResponse) return pluginResponse; + const environment = import.meta.env.DEV ? "development" : "production"; const canonicalRedirect = getCanonicalRedirect(url, environment); if (canonicalRedirect) { diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index bfbe26a82a..946176fb43 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -406,6 +406,12 @@ font-weight: 400; } +.docs-prose img { + max-width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--color-border); +} + .docs-prose p { margin-top: 1rem; margin-bottom: 1rem; diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index f9260acf49..46dbc554de 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -80,6 +80,11 @@ function discoverBlogRoutes(): string[] { return ["/blog", ...slugs.map((slug) => `/blog/${slug}`)]; } +// Author and detail URLs are served by /sitemap-plugins.xml as the registry changes. +function discoverPluginRoutes(): string[] { + return ["/plugins"]; +} + const sitemapPages = [ "/", "/agents", @@ -90,6 +95,7 @@ const sitemapPages = [ "/terms", ...discoverAgentRoutes(), ...discoverAlternativeRoutes(), + ...discoverPluginRoutes(), ...discoverDocsRoutes(), ...discoverBlogRoutes(), ].map((routePath) => ({ diff --git a/packages/website/wrangler.toml b/packages/website/wrangler.toml index 089a22089e..48db01ed78 100644 --- a/packages/website/wrangler.toml +++ b/packages/website/wrangler.toml @@ -7,6 +7,7 @@ main = "./src/server-entry.ts" routes = [ { pattern = "paseo.sh", custom_domain = true }, { pattern = "www.paseo.sh", custom_domain = true }, + { pattern = "plugins.paseo.sh", custom_domain = true }, ] [observability] diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 85e04bbdc3..fc473d99d0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2208,11 +2208,12 @@ directory. The app does not expand `~`; your shell may expand it before the CLI | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| Registry plugin | `owner/slug` or `host/owner/slug` | `omercnet/dracula` | | Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | -| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` | +| GitHub repository | `github:owner/repository` | `github:acme/paseo-review` | | Git repository | `git:`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:[@]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to an explicit npm/Git source | `github:acme/monorepo:plugins/review` | Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use `user@host:path`. `file://` selects Git acquisition, not directory installation. @@ -2237,8 +2238,9 @@ Paseo resolves an identifier in this order: across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix that does not satisfy these rules stays part of the identifier. 4. Without an explicit prefix, an existing directory matching the remaining source wins. -5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub - HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources. +5. Resolve bare `owner/slug` and `host/owner/slug` through the plugin registry. Registry records + own the artifact revision and plugin path. GitHub shorthand requires `github:`; `git:` also + accepts shorthand, URLs, and SCP sources. 6. Resolve a remaining npm package name with its optional selector through the host's registry. Reject anything else. @@ -2287,9 +2289,9 @@ deletes its managed files; removing a directory plugin keeps your source directo paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id -paseo plugin add owner/repository +paseo plugin add github:owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add github:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update paseo plugin update --all --check From 526ad1366f1053503352b0559fce7972c3e1b01d Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:38:39 +0000 Subject: [PATCH 013/281] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 13a3e1453b..fa431c78ff 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-NdlgVA9tBFmAFDkyo6pRmMiZSD3aXwGbkAHNyqCOVNI= +sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc= From 15d8ba16a0a2a5be50aeb78ea25416cfd1e59fca Mon Sep 17 00:00:00 2001 From: Yu Han <73627805+Mar-garet@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:53:11 +0800 Subject: [PATCH 014/281] fix(cli): handle negated schedule limit flags (#5973) --- packages/cli/src/commands/schedule/update.ts | 14 ++- packages/cli/tests/31-schedule.test.ts | 97 +++++++++++++++++++- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 81f3762962..7d0079e269 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -23,10 +23,8 @@ export interface ScheduleUpdateOptions extends ScheduleCommandOptions { model?: string; mode?: string; cwd?: string; - maxRuns?: string; - noMaxRuns?: boolean; - expiresIn?: string; - noExpiresIn?: boolean; + maxRuns?: string | false; + expiresIn?: string | false; } export async function runUpdateCommand( @@ -45,10 +43,10 @@ export async function runUpdateCommand( model: options.model, mode: options.mode, cwd: options.cwd, - maxRuns: options.maxRuns, - expiresIn: options.expiresIn, - clearMaxRuns: options.noMaxRuns, - clearExpires: options.noExpiresIn, + maxRuns: options.maxRuns === false ? undefined : options.maxRuns, + expiresIn: options.expiresIn === false ? undefined : options.expiresIn, + clearMaxRuns: options.maxRuns === false, + clearExpires: options.expiresIn === false, }); const { client } = await connectScheduleClient(options.daemonTarget); try { diff --git a/packages/cli/tests/31-schedule.test.ts b/packages/cli/tests/31-schedule.test.ts index 64cd6ca64b..8ecd098a07 100644 --- a/packages/cli/tests/31-schedule.test.ts +++ b/packages/cli/tests/31-schedule.test.ts @@ -130,7 +130,102 @@ try { } { - console.log("Test 1d: compatibility agent-target schedules remain deletable"); + console.log("Test 1d: schedule update clears limits with negated flags"); + const created = await ctx.paseo([ + "schedule", + "create", + "Review scheduled work", + "--cron", + "0 0 1 1 *", + "--provider", + "claude", + "--cwd", + ctx.workDir, + "--max-runs", + "3", + "--expires-in", + "1h", + "--json", + ]); + assert.strictEqual(created.exitCode, 0, created.stderr); + const { id } = JSON.parse(created.stdout); + + const inspect = async () => { + const result = await ctx.paseo(["schedule", "inspect", id, "--json"]); + assert.strictEqual(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); + }; + const initial = await inspect(); + assert.strictEqual(initial.maxRuns, 3); + assert.strictEqual(typeof initial.expiresAt, "string"); + + const renamed = await ctx.paseo([ + "schedule", + "update", + id, + "--name", + "renamed-review", + "--json", + ]); + assert.strictEqual(renamed.exitCode, 0, renamed.stderr); + const afterRename = await inspect(); + assert.strictEqual(afterRename.name, "renamed-review"); + assert.strictEqual(afterRename.maxRuns, initial.maxRuns); + assert.strictEqual(afterRename.expiresAt, initial.expiresAt); + + const clearedMaxRuns = await ctx.paseo(["schedule", "update", id, "--no-max-runs", "--json"]); + assert.strictEqual(clearedMaxRuns.exitCode, 0, clearedMaxRuns.stderr); + const withoutMaxRuns = await inspect(); + assert.strictEqual(withoutMaxRuns.maxRuns, null); + assert.strictEqual(withoutMaxRuns.expiresAt, initial.expiresAt); + + const clearedExpiration = await ctx.paseo([ + "schedule", + "update", + id, + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedExpiration.exitCode, 0, clearedExpiration.stderr); + const withoutExpiration = await inspect(); + assert.strictEqual(withoutExpiration.maxRuns, null); + assert.strictEqual(withoutExpiration.expiresAt, null); + + const updatedLimits = await ctx.paseo([ + "schedule", + "update", + id, + "--max-runs", + "5", + "--expires-in", + "2h", + "--json", + ]); + assert.strictEqual(updatedLimits.exitCode, 0, updatedLimits.stderr); + const withLimits = await inspect(); + assert.strictEqual(withLimits.maxRuns, 5); + assert.strictEqual(typeof withLimits.expiresAt, "string"); + + const clearedBoth = await ctx.paseo([ + "schedule", + "update", + id, + "--no-max-runs", + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedBoth.exitCode, 0, clearedBoth.stderr); + const withoutLimits = await inspect(); + assert.strictEqual(withoutLimits.maxRuns, null); + assert.strictEqual(withoutLimits.expiresAt, null); + + const deleted = await ctx.paseo(["schedule", "delete", id, "--json"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + console.log("schedule update clears limits\n"); + } + + { + console.log("Test 1e: compatibility agent-target schedules remain deletable"); const created = await ctx.paseo( [ "schedule", From 42806cdbf9ee979d3500111b2df17cf60850c131 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 16:56:59 +0100 Subject: [PATCH 015/281] Let plugins play audio on every client (#5976) * feat(plugins): play audio files on every client * fix(audio): preserve voice output and isolate waiting cues * fix(plugins): normalize audio MIME parameters * refactor(audio): share PCM conversion and keep plugin playback file-only --- .../app/e2e/browser/plugin-host-ui.spec.ts | 32 +- .../{voice => audio}/audio-engine-types.ts | 2 +- .../src/{voice => audio}/audio-engine.d.ts | 0 packages/app/src/audio/audio-engine.native.ts | 224 +++++++++++ .../src/{voice => audio}/audio-engine.web.ts | 190 +++------- packages/app/src/audio/file-playback.ts | 61 +++ packages/app/src/audio/index.ts | 3 + packages/app/src/audio/pcm/index.ts | 124 +++++++ packages/app/src/audio/play-audio.test.ts | 202 ++++++++++ packages/app/src/audio/play-audio.ts | 38 ++ packages/app/src/audio/playback.test.ts | 136 +++++++ packages/app/src/audio/playback.ts | 81 ++++ packages/app/src/contexts/session-context.tsx | 2 +- packages/app/src/contexts/voice-context.tsx | 4 +- .../use-dictation-audio-source.native.ts | 2 +- .../hooks/use-dictation-audio-source.web.ts | 36 +- packages/app/src/plugins/catalog-sync.tsx | 7 +- packages/app/src/plugins/client-runtime.ts | 7 +- packages/app/src/plugins/evaluate.ts | 1 + .../src/plugins/registry-lifecycle.test.ts | 22 +- .../src/plugins/registry-requirements.test.ts | 8 +- packages/app/src/plugins/registry.test.ts | 4 +- packages/app/src/plugins/registry.ts | 4 +- .../app/src/plugins/timeline/view.test.tsx | 7 +- .../workspace-panels/locations.test.ts | 3 + packages/app/src/voice/audio-engine.native.ts | 350 ------------------ packages/app/src/voice/voice-runtime.test.ts | 7 +- packages/app/src/voice/voice-runtime.ts | 40 +- packages/plugin/src/client/contracts.ts | 2 + public-docs/plugins/reference.md | 23 ++ 30 files changed, 1042 insertions(+), 580 deletions(-) rename packages/app/src/{voice => audio}/audio-engine-types.ts (88%) rename packages/app/src/{voice => audio}/audio-engine.d.ts (100%) create mode 100644 packages/app/src/audio/audio-engine.native.ts rename packages/app/src/{voice => audio}/audio-engine.web.ts (63%) create mode 100644 packages/app/src/audio/file-playback.ts create mode 100644 packages/app/src/audio/index.ts create mode 100644 packages/app/src/audio/pcm/index.ts create mode 100644 packages/app/src/audio/play-audio.test.ts create mode 100644 packages/app/src/audio/play-audio.ts create mode 100644 packages/app/src/audio/playback.test.ts create mode 100644 packages/app/src/audio/playback.ts delete mode 100644 packages/app/src/voice/audio-engine.native.ts diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index bd53c59272..84d786033a 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,5 +1,5 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; @@ -36,9 +36,18 @@ function ModalBody({ onSaved }) {
; } -function Surface() { +function Surface({ playAudio }) { + const [audioStatus, setAudioStatus] = useState("Audio ready"); + async function play(base64) { + setAudioStatus("Playing audio"); + try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); } + catch { setAudioStatus("Audio rejected"); } + } const [open, setOpen] = useState(false); return + {audioStatus} + play(AUDIO_BASE64)}>Play plugin audio + play("UklGRg==")}>Play invalid audio setOpen(true)}> @@ -59,7 +68,7 @@ function Surface() { } export default function contribute(plugin) { - plugin.addSurface("main", Surface); + plugin.addSurface("main", () => ); plugin.addSidebarItem({ id: "main", title: "Host UI", @@ -123,7 +132,11 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), ); - await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE); + const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav")); + await writeFile( + path.join(directory, "index.client.tsx"), + `const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`, + ); try { await client.patchDaemonConfig({ pluginsEnabled: true }); @@ -131,6 +144,12 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await useNonCompactLayout(page); await openHostUiPlugin(page); + await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => { + await playPluginAudio(page, "Play plugin audio", "Audio finished"); + await playPluginAudio(page, "Play invalid audio", "Audio rejected"); + await playPluginAudio(page, "Play plugin audio", "Audio finished"); + }); + await test.step("non-compact layouts use a centered dialog", async () => { await openPluginModal(page); await expectCenteredPluginDialog(page); @@ -153,3 +172,8 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await rm(directory, { recursive: true, force: true }); } }); + +async function playPluginAudio(page: Page, button: string, result: string) { + await page.getByRole("button", { name: button, exact: true }).click(); + await expect(page.getByText(result, { exact: true })).toBeVisible(); +} diff --git a/packages/app/src/voice/audio-engine-types.ts b/packages/app/src/audio/audio-engine-types.ts similarity index 88% rename from packages/app/src/voice/audio-engine-types.ts rename to packages/app/src/audio/audio-engine-types.ts index 03108b5cf6..90bec732fb 100644 --- a/packages/app/src/voice/audio-engine-types.ts +++ b/packages/app/src/audio/audio-engine-types.ts @@ -20,7 +20,7 @@ export interface AudioEngine { toggleMute(): boolean; isMuted(): boolean; - play(audio: AudioPlaybackSource): Promise; + play(audio: AudioPlaybackSource, signal?: AbortSignal): Promise; stop(): void; clearQueue(): void; isPlaying(): boolean; diff --git a/packages/app/src/voice/audio-engine.d.ts b/packages/app/src/audio/audio-engine.d.ts similarity index 100% rename from packages/app/src/voice/audio-engine.d.ts rename to packages/app/src/audio/audio-engine.d.ts diff --git a/packages/app/src/audio/audio-engine.native.ts b/packages/app/src/audio/audio-engine.native.ts new file mode 100644 index 0000000000..86a6fc3d26 --- /dev/null +++ b/packages/app/src/audio/audio-engine.native.ts @@ -0,0 +1,224 @@ +import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; + +import { createAudioPlayer, setAudioModeAsync } from "expo-audio"; +import { File, Paths } from "expo-file-system"; +import { createPlaybackQueue } from "./playback"; +import { playFile } from "./file-playback"; +import { playPcm16 } from "./pcm"; + +export function createAudioEngine( + callbacks: AudioEngineCallbacks, + _options?: { traceLabel?: string }, +): AudioEngine { + const native = require("@getpaseo/expo-two-way-audio"); + + const refs: { + initialized: boolean; + captureActive: boolean; + muted: boolean; + destroyed: boolean; + } = { + initialized: false, + captureActive: false, + muted: false, + destroyed: false, + }; + + const microphoneSubscription = native.addExpoTwoWayAudioEventListener( + "onMicrophoneData", + (event: { data: Uint8Array }) => { + if (!refs.captureActive || refs.muted) { + return; + } + const pcm = event.data; + callbacks.onCaptureData(pcm); + }, + ); + const volumeSubscription = native.addExpoTwoWayAudioEventListener( + "onInputVolumeLevelData", + (event: { data: number }) => { + if (!refs.captureActive) { + return; + } + const level = refs.muted ? 0 : event.data; + callbacks.onVolumeLevel(level); + }, + ); + const interruptionSubscription = native.addExpoTwoWayAudioEventListener( + "onAudioInterruption", + (event: { data: string }) => { + if (event.data !== "blocked") { + return; + } + const wasCaptureActive = refs.captureActive; + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + if (wasCaptureActive) { + callbacks.onInterruption?.(); + } + }, + ); + + async function ensureInitialized(): Promise { + if (refs.initialized) { + return; + } + const success = await native.initialize(); + if (!success) { + throw new Error("expo-two-way-audio: native initialize() returned false"); + } + refs.initialized = true; + } + + /** + * Release the OS audio session as soon as we are neither capturing nor playing. + * Holding it keeps the user's background music paused — on iOS the non-mixing + * `.playAndRecord` category survives backgrounding and is re-asserted on every + * foreground, so an unreleased session means their music never comes back. + */ + function releaseSessionIfIdle(): void { + if (!refs.initialized || refs.destroyed) { + return; + } + if (refs.captureActive || playback.isPlaying()) { + return; + } + // The wrapper no-ops on binaries whose native module predates this function. + native.releaseAudioSession(); + } + + async function ensureMicrophonePermission(): Promise { + let permission = await native.getMicrophonePermissionsAsync().catch(() => null); + if (!permission?.granted) { + permission = await native.requestMicrophonePermissionsAsync().catch(() => null); + } + if (!permission?.granted) { + throw new Error( + "Microphone permission is required to capture audio. Please enable microphone access in system settings.", + ); + } + } + + let nextFileId = 0; + async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise { + const bytes = new Uint8Array(await audio.arrayBuffer()); + if (signal.aborted) throw new Error("Playback stopped"); + if (audio.type.startsWith("audio/pcm")) { + await ensureInitialized(); + return playPcm16(bytes, audio.type, signal, native); + } + // Capture owns its audio session while active. File playback alone must not + // initialize the microphone or the native two-way engine. + if (!refs.captureActive) { + await setAudioModeAsync({ + playsInSilentMode: true, + allowsRecording: false, + interruptionMode: "duckOthers", + interruptionModeAndroid: "duckOthers", + }); + } + if (signal.aborted) throw new Error("Playback stopped"); + // AVPlayer needs a file extension to recognize local encoded audio on iOS. + const extension = + { + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/wave": "wav", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/mp4": "m4a", + "audio/aac": "aac", + "audio/ogg": "ogg", + "audio/flac": "flac", + }[audio.type.split(";")[0].trim()] ?? "audio"; + const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`); + try { + file.write(bytes); + const player = createAudioPlayer(file.uri, { + updateInterval: 100, + keepAudioSessionActive: refs.captureActive, + }); + return await playFile(player, signal); + } finally { + if (file.exists) file.delete(); + } + } + const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle); + + return { + async initialize() { + await ensureInitialized(); + }, + + async destroy() { + if (refs.destroyed) { + return; + } + refs.destroyed = true; + playback.destroy(); + if (refs.captureActive) { + native.toggleRecording(false); + refs.captureActive = false; + } + refs.muted = false; + callbacks.onVolumeLevel(0); + if (refs.initialized) { + native.tearDown(); + refs.initialized = false; + } + microphoneSubscription.remove(); + volumeSubscription.remove(); + interruptionSubscription.remove(); + }, + + async startCapture() { + if (refs.captureActive) { + return; + } + + try { + await ensureMicrophonePermission(); + await ensureInitialized(); + const isRecording = native.toggleRecording(true); + if (!isRecording) { + throw new Error( + "Microphone capture could not start because Android audio focus is unavailable.", + ); + } + refs.captureActive = true; + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + callbacks.onError?.(wrapped); + throw wrapped; + } + }, + + async stopCapture() { + if (refs.captureActive) { + native.toggleRecording(false); + } + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + releaseSessionIfIdle(); + }, + + toggleMute() { + refs.muted = !refs.muted; + if (refs.muted) { + callbacks.onVolumeLevel(0); + } + return refs.muted; + }, + + isMuted() { + return refs.muted; + }, + + play: playback.play, + stop: playback.stop, + clearQueue: playback.clearQueue, + isPlaying: playback.isPlaying, + }; +} diff --git a/packages/app/src/voice/audio-engine.web.ts b/packages/app/src/audio/audio-engine.web.ts similarity index 63% rename from packages/app/src/voice/audio-engine.web.ts rename to packages/app/src/audio/audio-engine.web.ts index 0453c0ae33..dc0f049ca7 100644 --- a/packages/app/src/voice/audio-engine.web.ts +++ b/packages/app/src/audio/audio-engine.web.ts @@ -1,15 +1,8 @@ +import { parsePcmSampleRate, resampleToPcm16 } from "./pcm"; import { isElectronRuntime } from "@/desktop/host"; -import type { - AudioEngine, - AudioEngineCallbacks, - AudioPlaybackSource, -} from "@/voice/audio-engine-types"; +import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; -interface QueuedAudio { - audio: AudioPlaybackSource; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} +import { createPlaybackQueue } from "./playback"; function getAudioContextCtor(): typeof AudioContext | null { if (typeof window === "undefined") { @@ -21,40 +14,6 @@ function getAudioContextCtor(): typeof AudioContext | null { return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null; } -function floatToInt16(sample: number): number { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -} - -function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array { - if (input.length === 0) { - return new Uint8Array(0); - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const output = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i += 1) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - output[i] = floatToInt16(sample); - } - - return new Uint8Array(output.buffer, output.byteOffset, output.byteLength); -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - function pcm16LeToAudioBuffer( context: AudioContext, bytes: Uint8Array, @@ -98,14 +57,6 @@ export function createAudioEngine( gain: GainNode | null; started: boolean; muted: boolean; - queue: QueuedAudio[]; - processingQueue: boolean; - activePlayback: { - source: AudioBufferSourceNode; - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null; } = { playbackContext: null, captureContext: null, @@ -115,29 +66,31 @@ export function createAudioEngine( gain: null, started: false, muted: false, - queue: [], - processingQueue: false, - activePlayback: null, }; async function ensurePlaybackContext(): Promise { - if (refs.playbackContext) { - if (refs.playbackContext.state === "suspended") { - await refs.playbackContext.resume().catch(() => undefined); - } - return refs.playbackContext; + if (!refs.playbackContext) { + const AudioContextCtor = getAudioContextCtor(); + if (!AudioContextCtor) throw new Error("AudioContext unavailable"); + refs.playbackContext = new AudioContextCtor(); } - - const AudioContextCtor = getAudioContextCtor(); - if (!AudioContextCtor) { - throw new Error("AudioContext unavailable"); - } - - const context = new AudioContextCtor(); + const context = refs.playbackContext; if (context.state === "suspended") { - await context.resume().catch(() => undefined); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + context.resume(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Audio playback requires a user interaction")), + 2000, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } } - refs.playbackContext = context; return context; } @@ -162,7 +115,7 @@ export function createAudioEngine( return context; } - async function playAudio(audio: AudioPlaybackSource): Promise { + async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise { const context = await ensurePlaybackContext(); const arrayBuffer = await audio.arrayBuffer(); const type = (audio.type || "").toLowerCase(); @@ -174,53 +127,39 @@ export function createAudioEngine( ) : await decodeAudioData(context, arrayBuffer); + if (signal.aborted) throw new Error("Playback stopped"); const durationSec = audioBuffer.duration; const source = context.createBufferSource(); source.buffer = audioBuffer; source.connect(context.destination); return await new Promise((resolve, reject) => { - refs.activePlayback = { source, resolve, reject, settled: false }; - - const settle = (fn: () => void) => { - const active = refs.activePlayback; - if (!active || active.source !== source || active.settled) { - return; - } - active.settled = true; - refs.activePlayback = null; - fn(); + const abort = () => { + source.stop(); + source.disconnect(); + reject(new Error("Playback stopped")); }; - - source.addEventListener("ended", () => { - settle(() => resolve(durationSec)); - }); - + signal.addEventListener("abort", abort, { once: true }); + source.addEventListener( + "ended", + () => { + signal.removeEventListener("abort", abort); + source.disconnect(); + resolve(durationSec); + }, + { once: true }, + ); try { source.start(); } catch (error) { - settle(() => reject(error instanceof Error ? error : new Error(String(error)))); + signal.removeEventListener("abort", abort); + source.disconnect(); + reject(error); } }); } - async function processQueue(): Promise { - if (refs.processingQueue || refs.queue.length === 0) { - return; - } - - refs.processingQueue = true; - while (refs.queue.length > 0) { - const item = refs.queue.shift()!; - try { - const duration = await playAudio(item.audio); - item.resolve(duration); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); - } - } - refs.processingQueue = false; - } + const playback = createPlaybackQueue(playAudio); async function stopCapture(): Promise { refs.started = false; @@ -263,8 +202,7 @@ export function createAudioEngine( }, async destroy() { - this.stop(); - this.clearQueue(); + playback.destroy(); await stopCapture(); const playbackContext = refs.playbackContext; @@ -334,7 +272,8 @@ export function createAudioEngine( return; } - callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000)); + const pcm = resampleToPcm16(input, context.sampleRate, 16000); + callbacks.onCaptureData(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength)); }; source.connect(processor); @@ -370,40 +309,9 @@ export function createAudioEngine( return refs.muted; }, - async play(audio: AudioPlaybackSource) { - return await new Promise((resolve, reject) => { - refs.queue.push({ audio, resolve, reject }); - if (!refs.processingQueue) { - void processQueue(); - } - }); - }, - - stop() { - if (refs.activePlayback) { - const active = refs.activePlayback; - refs.activePlayback = null; - try { - active.source.stop(); - } catch { - // Ignore best-effort stop errors. - } - if (!active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - } - }, - - clearQueue() { - while (refs.queue.length > 0) { - refs.queue.shift()!.reject(new Error("Playback stopped")); - } - refs.processingQueue = false; - }, - - isPlaying() { - return refs.activePlayback !== null; - }, + play: playback.play, + stop: playback.stop, + clearQueue: playback.clearQueue, + isPlaying: playback.isPlaying, }; } diff --git a/packages/app/src/audio/file-playback.ts b/packages/app/src/audio/file-playback.ts new file mode 100644 index 0000000000..6d4370bd6f --- /dev/null +++ b/packages/app/src/audio/file-playback.ts @@ -0,0 +1,61 @@ +export interface FilePlaybackStatus { + playbackState: string; + isLoaded: boolean; + didJustFinish: boolean; + duration: number; +} + +export interface FilePlayer { + play(): void; + remove(): void; + addListener( + event: "playbackStatusUpdate", + listener: (status: FilePlaybackStatus) => void, + ): { remove(): void }; +} + +/** Own completion, load failure and resource release for the native file backend. */ +export function playFile(player: FilePlayer, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false; + // expo-audio 1.x does not report Android decoder errors. A local file that + // never becomes loaded must still release its queue slot and temporary file. + let timeout: ReturnType | undefined; + const finish = (error?: unknown, duration = 0) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + subscription.remove(); + player.remove(); + if (error) reject(error); + else resolve(duration); + }; + const abort = () => finish(new Error("Playback stopped")); + let loaded = false; + const subscription = player.addListener("playbackStatusUpdate", (status) => { + if (status.playbackState === "error" || status.playbackState === "failed") { + finish(new Error("Audio file could not be decoded")); + } else if (status.didJustFinish) { + finish(undefined, status.duration); + } else if (status.isLoaded && status.duration > 0 && !loaded) { + loaded = true; + clearTimeout(timeout); + timeout = setTimeout( + () => finish(new Error("Audio playback timed out")), + (status.duration + 30) * 1000, + ); + } + }); + timeout = setTimeout(() => finish(new Error("Audio file could not be loaded")), 15000); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + else { + try { + player.play(); + } catch (error) { + finish(error); + } + } + }); +} diff --git a/packages/app/src/audio/index.ts b/packages/app/src/audio/index.ts new file mode 100644 index 0000000000..fd5bbfd221 --- /dev/null +++ b/packages/app/src/audio/index.ts @@ -0,0 +1,3 @@ +export { createAudioEngine } from "./audio-engine"; +export type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; +export { createPlayAudio } from "./play-audio"; diff --git a/packages/app/src/audio/pcm/index.ts b/packages/app/src/audio/pcm/index.ts new file mode 100644 index 0000000000..fe1164b8ce --- /dev/null +++ b/packages/app/src/audio/pcm/index.ts @@ -0,0 +1,124 @@ +const floatToInt16 = (sample: number): number => { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); +}; + +export const resampleToPcm16 = ( + input: Float32Array, + inputRate: number, + outputRate: number, +): Int16Array => { + if (input.length === 0) { + return new Int16Array(0); + } + if (inputRate === outputRate) { + const out = new Int16Array(input.length); + for (let i = 0; i < input.length; i++) { + out[i] = floatToInt16(input[i]); + } + return out; + } + + const ratio = inputRate / outputRate; + const outputLength = Math.max(1, Math.round(input.length / ratio)); + const out = new Int16Array(outputLength); + for (let i = 0; i < outputLength; i++) { + const sourceIndex = i * ratio; + const i0 = Math.floor(sourceIndex); + const i1 = Math.min(input.length - 1, i0 + 1); + const frac = sourceIndex - i0; + const sample = input[i0] * (1 - frac) + input[i1] * frac; + out[i] = floatToInt16(sample); + } + return out; +}; + +export function parsePcmSampleRate(mimeType: string): number | null { + const match = /rate=(\d+)/i.exec(mimeType); + if (!match) { + return null; + } + const rate = Number(match[1]); + return Number.isFinite(rate) && rate > 0 ? rate : null; +} + +function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { + if (fromRate === toRate) { + return pcm; + } + + const inputSamples = Math.floor(pcm.length / 2); + const outputSamples = Math.floor((inputSamples * toRate) / fromRate); + const out = new Uint8Array(outputSamples * 2); + const ratio = fromRate / toRate; + + const readInt16 = (sampleIndex: number): number => { + const i = sampleIndex * 2; + if (i + 1 >= pcm.length) { + return 0; + } + const lo = pcm[i]; + const hi = pcm[i + 1]; + let value = (hi << 8) | lo; + if (value & 0x8000) { + value = value - 0x10000; + } + return value; + }; + + const writeInt16 = (sampleIndex: number, value: number): void => { + const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); + const i = sampleIndex * 2; + out[i] = clamped & 0xff; + out[i + 1] = (clamped >> 8) & 0xff; + }; + + for (let i = 0; i < outputSamples; i++) { + const srcPos = i * ratio; + const i0 = Math.floor(srcPos); + const frac = srcPos - i0; + const s0 = readInt16(i0); + const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); + writeInt16(i, s0 + (s1 - s0) * frac); + } + + return out; +} + +interface PcmOutput { + resumePlayback(): void; + playPCMData(bytes: Uint8Array): void; + stopPlayback(): void; +} + +/** Keep voice output on the native communication engine and its echo reference. */ +export function playPcm16( + bytes: Uint8Array, + mimeType: string, + signal: AbortSignal, + output: PcmOutput, +): Promise { + if (signal.aborted) return Promise.reject(new Error("Playback stopped")); + const pcm = resamplePcm16(bytes, parsePcmSampleRate(mimeType) ?? 24000, 16000); + const duration = pcm.length / 2 / 16000; + return new Promise((resolve, reject) => { + let timeout: ReturnType | undefined; + const abort = () => { + clearTimeout(timeout); + output.stopPlayback(); + reject(new Error("Playback stopped")); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + output.resumePlayback(); + output.playPCMData(pcm); + timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(duration); + }, duration * 1000); + } catch (error) { + signal.removeEventListener("abort", abort); + reject(error); + } + }); +} diff --git a/packages/app/src/audio/play-audio.test.ts b/packages/app/src/audio/play-audio.test.ts new file mode 100644 index 0000000000..a47b5c0ecc --- /dev/null +++ b/packages/app/src/audio/play-audio.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "vitest"; +import { createPlayAudio } from "./play-audio"; +import { playPcm16, parsePcmSampleRate, resampleToPcm16 } from "./pcm"; +import { playFile, type FilePlaybackStatus, type FilePlayer } from "./file-playback"; + +const source = { base64: "UklGRg==", mimeType: "audio/wav" }; +test("passes RPC file bytes to the shared engine and awaits completion", async () => { + const owner = new AbortController(); + let finish!: () => void; + const played = new Promise((resolve) => { + finish = resolve; + }); + let completed = false; + const play = createPlayAudio( + { + async play(audio, signal) { + expect(signal).toBe(owner.signal); + expect(audio.type).toBe("audio/wav"); + expect(audio.size).toBe(4); + expect(new Uint8Array(await audio.arrayBuffer())).toEqual(new Uint8Array([82, 73, 70, 70])); + await played; + return 1; + }, + }, + owner.signal, + ); + const result = play(source).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + finish(); + await result; + expect(completed).toBe(true); +}); + +test.each([ + { ...source, base64: "garbage!" }, + { ...source, base64: "" }, + { ...source, mimeType: "text/plain" }, +])("rejects invalid input before reaching a decoder: %o", async (input) => { + const play = createPlayAudio( + { + play() { + throw new Error("Reached decoder"); + }, + }, + new AbortController().signal, + ); + await expect(play(input)).rejects.toThrow(/^Audio must/); +}); + +test("rejects calls from an unloaded plugin", async () => { + const owner = new AbortController(); + owner.abort(new Error("Plugin unloaded")); + const play = createPlayAudio( + { + play() { + throw new Error("Reached decoder"); + }, + }, + owner.signal, + ); + await expect(play(source)).rejects.toThrow("Playback stopped"); +}); + +test("voice PCM retains its native output and 16 kHz samples", async () => { + const pcm = new Uint8Array([0, 0, 255, 127, 0, 128]); + let written: Uint8Array | undefined; + const duration = await playPcm16( + pcm, + "audio/pcm;rate=16000;bits=16", + new AbortController().signal, + { + resumePlayback() {}, + playPCMData(bytes) { + written = bytes; + }, + stopPlayback() {}, + }, + ); + expect(written).toEqual(pcm); + expect(duration).toBe(3 / 16000); +}); + +test("voice PCM cancellation stops native output and rejects", async () => { + let stopped = false; + const owner = new AbortController(); + const result = playPcm16(new Uint8Array(32000), "audio/pcm;rate=16000;bits=16", owner.signal, { + resumePlayback() {}, + playPCMData() {}, + stopPlayback() { + stopped = true; + }, + }); + owner.abort(); + await expect(result).rejects.toThrow("Playback stopped"); + expect(stopped).toBe(true); +}); + +function filePlayer() { + let listener: (status: FilePlaybackStatus) => void = () => {}; + let removed = false; + let started = false; + const player: FilePlayer = { + play() { + started = true; + }, + remove() { + removed = true; + }, + addListener(_event, callback) { + listener = callback; + return { + remove() { + listener = () => {}; + }, + }; + }, + }; + return { + player, + status: (status: Partial) => + listener({ + playbackState: "ready", + duration: 1, + didJustFinish: false, + isLoaded: true, + ...status, + }), + removed: () => removed, + started: () => started, + }; +} + +test("native file playback waits for completion and releases its player", async () => { + const h = filePlayer(); + const promise = playFile(h.player, new AbortController().signal); + expect(h.started()).toBe(true); + h.status({ didJustFinish: true }); + await expect(promise).resolves.toBe(1); + expect(h.removed()).toBe(true); +}); + +test("native decoder errors reject and release the player", async () => { + const h = filePlayer(); + const promise = playFile(h.player, new AbortController().signal); + h.status({ playbackState: "failed" }); + await expect(promise).rejects.toThrow("could not be decoded"); + expect(h.removed()).toBe(true); +}); + +test("native cancellation releases playback and settles the caller", async () => { + const h = filePlayer(); + const owner = new AbortController(); + const promise = playFile(h.player, owner.signal); + owner.abort(new Error("Plugin unloaded")); + await expect(promise).rejects.toThrow("Playback stopped"); + expect(h.removed()).toBe(true); +}); + +test.each(["audio/pcm;rate=16000;bits=16", "AUDIO/PCM; rate=16000"])( + "rejects raw PCM instead of initializing voice capture: %s", + async (mimeType) => { + const play = createPlayAudio( + { + play: async () => { + throw new Error("Reached engine"); + }, + }, + new AbortController().signal, + ); + await expect(play({ base64: "AAAAAA==", mimeType })).rejects.toThrow( + "Pass an audio file, such as WAV or MP3", + ); + }, +); + +// Voice capture and browser dictation use this same PCM boundary. +test("capture PCM preserves clipping, signed samples, and empty input", () => { + expect(resampleToPcm16(new Float32Array([-2, -1, -0.5, 0, 0.5, 1, 2]), 16000, 16000)).toEqual( + new Int16Array([-32768, -32768, -16384, 0, 16384, 32767, 32767]), + ); + expect(resampleToPcm16(new Float32Array(), 48000, 16000)).toEqual(new Int16Array()); +}); + +test("capture PCM resamples with interpolation and preserves the last sample", () => { + expect(resampleToPcm16(new Float32Array([-1, 0, 1]), 24000, 16000)).toEqual( + new Int16Array([-32768, 16384]), + ); + expect(resampleToPcm16(new Float32Array([0, 1]), 8000, 16000)).toEqual( + new Int16Array([0, 16384, 32767, 32767]), + ); +}); + +test("voice playback shares PCM sample-rate parsing", () => { + expect(parsePcmSampleRate("audio/pcm;rate=16000;bits=16")).toBe(16000); + expect(parsePcmSampleRate("audio/PCM;RATE=48000")).toBe(48000); + expect(parsePcmSampleRate("audio/pcm")).toBeNull(); + expect(parsePcmSampleRate("audio/pcm;rate=0")).toBeNull(); +}); diff --git a/packages/app/src/audio/play-audio.ts b/packages/app/src/audio/play-audio.ts new file mode 100644 index 0000000000..e5d831de17 --- /dev/null +++ b/packages/app/src/audio/play-audio.ts @@ -0,0 +1,38 @@ +import { Buffer } from "buffer"; +import type { AudioEngine } from "./audio-engine-types"; + +/** RPC-friendly file input. No browser Blob, URL, or platform knowledge is needed. */ +export function createPlayAudio(engine: Pick, signal: AbortSignal) { + return async (source: { base64: string; mimeType: string }): Promise => { + if (signal.aborted) throw new Error("Playback stopped"); + if ( + !source || + typeof source.base64 !== "string" || + !/^[A-Za-z0-9+/]+={0,2}$/.test(source.base64) || + source.base64.length % 4 !== 0 || + !source.base64.length + ) { + throw new Error("Audio must contain a non-empty base64-encoded file"); + } + if ( + typeof source.mimeType !== "string" || + !/^audio\/[\w.+-]+(?:[ \t]*;[^\r\n]+)?$/i.test(source.mimeType) + ) { + throw new Error("Audio must have an audio MIME type, such as audio/wav"); + } + const bytes = Uint8Array.from(Buffer.from(source.base64, "base64")); + const [mediaType, ...parameters] = source.mimeType.split(";"); + const type = [mediaType.trim().toLowerCase(), ...parameters].join(";"); + if (mediaType.trim().toLowerCase() === "audio/pcm") { + throw new Error("Pass an audio file, such as WAV or MP3; raw PCM is not supported"); + } + await engine.play( + { + type, + size: bytes.byteLength, + arrayBuffer: async () => bytes.buffer, + }, + signal, + ); + }; +} diff --git a/packages/app/src/audio/playback.test.ts b/packages/app/src/audio/playback.test.ts new file mode 100644 index 0000000000..2627d899cf --- /dev/null +++ b/packages/app/src/audio/playback.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "vitest"; +import { createPlaybackQueue } from "./playback"; + +function harness() { + const started: string[] = []; + const finishes: (() => void)[] = []; + const queue = createPlaybackQueue((source, signal) => { + started.push(source); + return new Promise((resolve, reject) => { + finishes.push(() => resolve(1)); + signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { once: true }); + }); + }); + return { queue, started, finishes }; +} + +test("plays in order and resolves only after playback completes", async () => { + const h = harness(); + const first = h.queue.play("first"); + const second = h.queue.play("second"); + expect(h.started).toEqual(["first"]); + h.finishes[0](); + await expect(first).resolves.toBe(1); + expect(h.started).toEqual(["first", "second"]); + h.finishes[1](); + await expect(second).resolves.toBe(1); +}); + +test("cancels one owner's queued and active work without stopping another owner", async () => { + const h = harness(); + const owner = new AbortController(); + const first = h.queue.play("first", owner.signal); + const queued = h.queue.play("cancelled", owner.signal); + const other = h.queue.play("other"); + const rejected = Promise.all([ + expect(first).rejects.toThrow("Playback stopped"), + expect(queued).rejects.toThrow("Playback stopped"), + ]); + owner.abort(new Error("unloaded")); + await rejected; + expect(h.started).toEqual(["first", "other"]); + h.finishes[1](); + await expect(other).resolves.toBe(1); +}); + +test("stop during loading cannot start cancelled audio or strand the next call", async () => { + const h = harness(); + const first = h.queue.play("loading"); + const rejected = expect(first).rejects.toThrow("Playback stopped"); + h.queue.stop(); + await rejected; + const second = h.queue.play("next"); + h.finishes[1](); + await expect(second).resolves.toBe(1); +}); + +test("a failed file does not block the queue", async () => { + const queue = createPlaybackQueue(async (source) => { + if (source === "invalid") throw new Error("Invalid audio"); + return 2; + }); + const failed = queue.play("invalid"); + const next = queue.play("valid"); + await expect(failed).rejects.toThrow("Invalid audio"); + await expect(next).resolves.toBe(2); +}); + +test("ending the voice waiting cue leaves a plugin's active audio playing", async () => { + const { createVoiceRuntime } = await import("../voice/voice-runtime"); + let finishPlugin!: () => void; + let cueQueued!: () => void; + const queued = new Promise((resolve) => { + cueQueued = resolve; + }); + const output = createPlaybackQueue<{ type: string }>( + (_source, signal) => + new Promise((resolve, reject) => { + finishPlugin = () => resolve(1); + signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { + once: true, + }); + }), + ); + const engine = { + initialize: async () => {}, + destroy: async () => { + output.destroy(); + }, + startCapture: async () => {}, + stopCapture: async () => {}, + toggleMute: () => false, + isMuted: () => false, + play(source: { type: string }, signal?: AbortSignal) { + if (source.type.startsWith("audio/pcm")) cueQueued(); + return output.play(source, signal); + }, + stop: output.stop, + clearQueue: output.clearQueue, + isPlaying: output.isPlaying, + }; + const runtime = createVoiceRuntime({ + engine, + getServerInfo: () => ({ + serverId: "host", + hostname: "host", + version: "1.0.0", + capabilities: { + voice: { dictation: { enabled: true, reason: "" }, voice: { enabled: true, reason: "" } }, + }, + }), + activateKeepAwake: async () => {}, + deactivateKeepAwake: async () => {}, + }); + runtime.registerSession({ + serverId: "host", + setVoiceMode: async () => {}, + sendVoiceAudioChunk: async () => {}, + audioPlayed: async () => {}, + abortRequest: async () => {}, + setAssistantAudioPlaying: () => {}, + }); + try { + await runtime.startVoice("host", "agent"); + const result = engine.play({ type: "audio/wav" }).then( + () => "finished", + () => "stopped", + ); + runtime.onTurnEvent("host", "agent", "turn_started"); + await queued; + runtime.onAssistantAudioStarted("host"); + finishPlugin(); + expect(await result).toBe("finished"); + } finally { + await runtime.destroy(); + } +}); diff --git a/packages/app/src/audio/playback.ts b/packages/app/src/audio/playback.ts new file mode 100644 index 0000000000..8d0744b79c --- /dev/null +++ b/packages/app/src/audio/playback.ts @@ -0,0 +1,81 @@ +/** One queue for voice and plugin audio, including cancellation while loading. */ +export function createPlaybackQueue( + start: (source: Source, signal: AbortSignal) => Promise, + onIdle: () => void = () => {}, +) { + interface Item { + source: Source; + controller: AbortController; + resolve: (duration: number) => void; + reject: (error: unknown) => void; + release: () => void; + } + const pending: Item[] = []; + let active: Item | null = null; + let destroyed = false; + + async function drain() { + if (active) return; + const item = pending.shift(); + if (!item) { + onIdle(); + return; + } + active = item; + try { + if (item.controller.signal.aborted) throw new Error("Playback stopped"); + item.resolve(await start(item.source, item.controller.signal)); + } catch (error) { + item.reject(error); + } finally { + item.release(); + active = null; + void drain(); + } + } + + const stopped = () => new Error("Playback stopped"); + return { + play(source: Source, signal?: AbortSignal): Promise { + if (destroyed) return Promise.reject(new Error("Audio engine destroyed")); + if (signal?.aborted) return Promise.reject(new Error("Playback stopped")); + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const cancel = () => { + controller.abort(); + const index = pending.indexOf(item); + if (index !== -1) { + pending.splice(index, 1); + item.release(); + reject(new Error("Playback stopped")); + } + }; + const item: Item = { + source, + controller, + resolve, + reject, + release: () => signal?.removeEventListener("abort", cancel), + }; + signal?.addEventListener("abort", cancel, { once: true }); + pending.push(item); + void drain(); + }); + }, + stop() { + active?.controller.abort(); + }, + clearQueue() { + for (const item of pending.splice(0)) { + item.release(); + item.reject(stopped()); + } + }, + isPlaying: () => active !== null, + destroy() { + destroyed = true; + this.clearQueue(); + this.stop(); + }, + }; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6a2c42a617..3e8165146c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -29,7 +29,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; -import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; +import type { AudioPlaybackSource } from "@/audio"; import { selectAgentTimelineState, toDaemonServerInfo, diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 7bb0b9b7e1..4c8d04e2f1 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -9,8 +9,8 @@ import { } from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSessionStore } from "@/stores/session-store"; -import { createAudioEngine } from "@/voice/audio-engine"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import { createAudioEngine } from "@/audio"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceRuntime, diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index aa50a8eead..293412fe1a 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Buffer } from "buffer"; import { useState } from "react"; -import { createAudioEngine } from "@/voice/audio-engine"; +import { createAudioEngine } from "@/audio"; import type { DictationAudioSource, diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index afb4b44499..c482bece18 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,3 +1,4 @@ +import { resampleToPcm16 } from "@/audio/pcm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isElectronRuntime } from "@/desktop/host"; @@ -17,41 +18,6 @@ const getAudioContextCtor = (): typeof AudioContext | null => { return ctor ?? null; }; -const floatToInt16 = (sample: number): number => { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -}; - -const resampleToPcm16 = ( - input: Float32Array, - inputRate: number, - outputRate: number, -): Int16Array => { - if (input.length === 0) { - return new Int16Array(0); - } - if (inputRate === outputRate) { - const out = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { - out[i] = floatToInt16(input[i]); - } - return out; - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const out = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i++) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - out[i] = floatToInt16(sample); - } - return out; -}; - const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => { if (a.length === 0) { return b; diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 484124971e..0eedfde3ab 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,3 +1,4 @@ +import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -12,13 +13,14 @@ export function PluginCatalogSync({ serverId: string; client: DaemonClient; }) { + const audio = useVoiceAudioEngineOptional(); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); useEffect(() => { let cancelled = false; let refreshQueue = Promise.resolve(); - if (!supported) { + if (!supported || !audio) { pluginRegistry.removeHost(serverId); return; } @@ -35,6 +37,7 @@ export function PluginCatalogSync({ pluginRegistry.installCatalog(serverId, catalog, { replacePluginId, client, + audio, }); } return undefined; @@ -79,7 +82,7 @@ export function PluginCatalogSync({ .release() .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); }; - }, [client, connected, serverId, supported]); + }, [audio, client, connected, serverId, supported]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 68534a0579..f9074383bd 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,3 +1,4 @@ +import { createPlayAudio, type AudioEngine } from "@/audio"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { createPluginHosts } from "./hosts"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; @@ -12,7 +13,10 @@ import { createPluginNavigation } from "./navigation"; import { pluginButtonStore } from "./buttons"; import type { InstalledPlugin } from "./types"; -export function createPluginClientRuntime(installation: InstalledPlugin): PluginClientRuntime { +export function createPluginClientRuntime( + installation: InstalledPlugin, + audio: Pick, +): PluginClientRuntime { const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( installation, @@ -20,6 +24,7 @@ export function createPluginClientRuntime(installation: InstalledPlugin): Plugin ); return { ...capabilities, + playAudio: createPlayAudio(audio, installation.lifetime.signal), hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index ecb8f89084..a2703b6ad2 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -91,6 +91,7 @@ function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle export type PluginClientRuntime = Pick< PluginClientContext, + | "playAudio" | "paseo" | "rpc" | "openSettings" diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 445269c87e..ecec420e3c 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -4,6 +4,8 @@ import type { PaseoApi } from "@getpaseo/client"; import { expect, test } from "vitest"; import { PluginRegistry } from "./registry"; +const audio = { play: async () => 0 }; + /** A host client that records open event observations by their first event name. */ function observingClient() { const open = new Set(); @@ -47,6 +49,7 @@ function registry() { openScreen: () => {}, openSurface: () => {}, openSettings: () => {}, + playAudio: async () => {}, openPanel: () => {}, addComposerPill: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }), @@ -67,7 +70,7 @@ function catalog(id: string, body = "return function() {};") { test("setup and every surface share the installation's one Paseo client", () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio }); const [installation] = h.plugins.getSnapshot(); expect(h.setupClients.get("deploys")).toBe(installation.paseo); @@ -76,11 +79,14 @@ test("setup and every surface share the installation's one Paseo client", () => test("teardown ends every subscription the plugin still holds", async () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { + client: h.client, + audio, + }); expect(h.open).toEqual(new Set(["disabled", "kept"])); // Disabling one plugin removes it from the catalog. - h.plugins.installCatalog("host", [catalog("kept")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio }); await expect.poll(() => [...h.open]).toEqual(["kept"]); h.plugins.removeHost("host"); @@ -89,11 +95,12 @@ test("teardown ends every subscription the plugin still holds", async () => { test("reloading a plugin ends the old installation's subscriptions", async () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio }); const [first] = h.plugins.getSnapshot(); h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, + audio, replacePluginId: "reloaded", }); @@ -109,6 +116,7 @@ test("failed plugin initialization ends the subscriptions it opened", async () = const h = registry(); h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { client: h.client, + audio, }); await expect.poll(() => h.open.size).toBe(0); expect(h.plugins.getSnapshot()).toEqual([]); @@ -118,8 +126,8 @@ test("unloading ends the subscriptions even when plugin cleanup throws, preservi const h = registry(); const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };'); const surviving = catalog("surviving", "return function() {};"); - h.plugins.installCatalog("host", [failing, surviving], { client: h.client }); - h.plugins.installCatalog("host", [surviving], { client: h.client }); + h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio }); + h.plugins.installCatalog("host", [surviving], { client: h.client, audio }); await expect.poll(() => [...h.open]).toEqual(["surviving"]); expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); h.plugins.removeHost("host"); @@ -131,7 +139,7 @@ test("an invalid async client entry is disposed and its rejected continuation is h.plugins.installCatalog( "host", [catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')], - { client: h.client }, + { client: h.client, audio }, ); await expect.poll(() => h.open.size).toBe(0); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 055fe207cd..eb141e94c2 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -3,6 +3,8 @@ import { afterEach, expect, it } from "vitest"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { PluginRegistry } from "./registry"; +const audio = { play: async () => 0 }; + const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" }); const registries: PluginRegistry[] = []; function registry(version: string) { @@ -31,6 +33,7 @@ function registry(version: string) { }, openScreen() {}, openSurface() {}, + async playAudio() {}, openPanel() {}, addComposerPill: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }), @@ -57,7 +60,7 @@ it("checks the app version before creating a runtime or evaluating plugin code", clientBundle: "throw new Error('executed')", }, ], - { client }, + { client, audio }, ); expect(starts()).toBe(0); expect(result.getSnapshot()).toEqual([]); @@ -68,7 +71,7 @@ it("checks the app version before creating a runtime or evaluating plugin code", it("rejects catalogs without requirements from pre-0.8 daemons", () => { const { result, starts } = registry("0.8.0"); - result.installCatalog("host", [{ id: "example", clientBundle }], { client }); + result.installCatalog("host", [{ id: "example", clientBundle }], { client, audio }); expect(starts()).toBe(0); expect(result.getEvaluationError("host", "example")).toContain( "https://paseo.sh/docs/plugins/migration", @@ -80,6 +83,7 @@ it("unloads on a requirement-only edit and recovers after correction", () => { const install = (paseo: string) => result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], { client, + audio, }); install("^0.8.0"); expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 45e1ba546f..9e8d246a95 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry as registry } from "./registry"; +const audio = { play: async () => 0 }; + vi.mock("./navigation", () => ({ createPluginNavigation: () => ({}), })); @@ -30,7 +32,7 @@ const pluginRegistry = { return registry.installCatalog( serverId, catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), - { ...options, client: daemonClient }, + { ...options, client: daemonClient, audio }, ); }, }; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index eed76a530f..e349446bfe 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,3 +1,4 @@ +import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; import { createPaseoApi } from "@getpaseo/client"; @@ -41,6 +42,7 @@ export class PluginRegistry { options: { replacePluginId?: string; client: DaemonClient; + audio: Pick; }, ): boolean { const previous = this.byHost.get(serverId) ?? []; @@ -99,7 +101,7 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - const runtime = this.dependencies.createRuntime(installation); + const runtime = this.dependencies.createRuntime(installation, options.audio); const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => this.publish(), ); diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index af1e711eb5..d915216105 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -39,6 +39,8 @@ vi.mock("../icons", () => ({ import { pluginRegistry } from "../registry"; import { PluginTimelineItemView } from "./view"; +const audio = { play: async () => 0 }; + const bundle = `(function(require) { const React = require("react"); return { default: function(plugin) { @@ -122,6 +124,7 @@ describe("PluginTimelineItemView", () => { [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], { client: daemonClient, + audio, }, ); @@ -151,6 +154,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -188,6 +192,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -310,7 +315,7 @@ it("a renderer releases its observation on the plugin's client when it crashes, clientBundle: liveBundle, }, ], - { client }, + { client, audio }, ); const container = document.createElement("div"); containers.push(container); diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index b475dd501d..af5cade055 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -4,6 +4,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry } from "../registry"; import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations"; +const audio = { play: async () => 0 }; + vi.mock("../navigation", () => ({ createPluginNavigation: () => ({}), })); @@ -37,6 +39,7 @@ function install(locations: readonly ("workspace" | "explorer")[]) { [{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], { client: {} as DaemonClient, + audio, }, ); return pluginRegistry.getSnapshot()[0]!; diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts deleted file mode 100644 index 465bab3c73..0000000000 --- a/packages/app/src/voice/audio-engine.native.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { - AudioEngine, - AudioEngineCallbacks, - AudioPlaybackSource, -} from "@/voice/audio-engine-types"; - -interface QueuedAudio { - audio: AudioPlaybackSource; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} - -interface AudioEngineTraceOptions { - traceLabel?: string; -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - -function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { - if (fromRate === toRate) { - return pcm; - } - - const inputSamples = Math.floor(pcm.length / 2); - const outputSamples = Math.floor((inputSamples * toRate) / fromRate); - const out = new Uint8Array(outputSamples * 2); - const ratio = fromRate / toRate; - - const readInt16 = (sampleIndex: number): number => { - const i = sampleIndex * 2; - if (i + 1 >= pcm.length) { - return 0; - } - const lo = pcm[i]; - const hi = pcm[i + 1]; - let value = (hi << 8) | lo; - if (value & 0x8000) { - value = value - 0x10000; - } - return value; - }; - - const writeInt16 = (sampleIndex: number, value: number): void => { - const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); - const i = sampleIndex * 2; - out[i] = clamped & 0xff; - out[i + 1] = (clamped >> 8) & 0xff; - }; - - for (let i = 0; i < outputSamples; i++) { - const srcPos = i * ratio; - const i0 = Math.floor(srcPos); - const frac = srcPos - i0; - const s0 = readInt16(i0); - const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); - writeInt16(i, s0 + (s1 - s0) * frac); - } - - return out; -} - -export function createAudioEngine( - callbacks: AudioEngineCallbacks, - _options?: AudioEngineTraceOptions, -): AudioEngine { - const native = require("@getpaseo/expo-two-way-audio"); - - const refs: { - initialized: boolean; - captureActive: boolean; - muted: boolean; - queue: QueuedAudio[]; - processingQueue: boolean; - playbackTimeout: ReturnType | null; - activePlayback: { - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null; - destroyed: boolean; - } = { - initialized: false, - captureActive: false, - muted: false, - queue: [], - processingQueue: false, - playbackTimeout: null, - activePlayback: null, - destroyed: false, - }; - - const microphoneSubscription = native.addExpoTwoWayAudioEventListener( - "onMicrophoneData", - (event: { data: Uint8Array }) => { - if (!refs.captureActive || refs.muted) { - return; - } - const pcm = event.data; - callbacks.onCaptureData(pcm); - }, - ); - const volumeSubscription = native.addExpoTwoWayAudioEventListener( - "onInputVolumeLevelData", - (event: { data: number }) => { - if (!refs.captureActive) { - return; - } - const level = refs.muted ? 0 : event.data; - callbacks.onVolumeLevel(level); - }, - ); - const interruptionSubscription = native.addExpoTwoWayAudioEventListener( - "onAudioInterruption", - (event: { data: string }) => { - if (event.data !== "blocked") { - return; - } - const wasCaptureActive = refs.captureActive; - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - if (wasCaptureActive) { - callbacks.onInterruption?.(); - } - }, - ); - - async function ensureInitialized(): Promise { - if (refs.initialized) { - return; - } - const success = await native.initialize(); - if (!success) { - throw new Error("expo-two-way-audio: native initialize() returned false"); - } - refs.initialized = true; - } - - /** - * Release the OS audio session as soon as we are neither capturing nor playing. - * Holding it keeps the user's background music paused — on iOS the non-mixing - * `.playAndRecord` category survives backgrounding and is re-asserted on every - * foreground, so an unreleased session means their music never comes back. - */ - function releaseSessionIfIdle(): void { - if (!refs.initialized || refs.destroyed) { - return; - } - if (refs.captureActive || refs.activePlayback || refs.queue.length > 0) { - return; - } - // The wrapper no-ops on binaries whose native module predates this function. - native.releaseAudioSession(); - } - - async function ensureMicrophonePermission(): Promise { - let permission = await native.getMicrophonePermissionsAsync().catch(() => null); - if (!permission?.granted) { - permission = await native.requestMicrophonePermissionsAsync().catch(() => null); - } - if (!permission?.granted) { - throw new Error( - "Microphone permission is required to capture audio. Please enable microphone access in system settings.", - ); - } - } - - function clearPlaybackTimeout(): void { - if (refs.playbackTimeout) { - clearTimeout(refs.playbackTimeout); - refs.playbackTimeout = null; - } - } - - async function playAudio(audio: AudioPlaybackSource): Promise { - await ensureInitialized(); - - return await new Promise((resolve, reject) => { - refs.activePlayback = { resolve, reject, settled: false }; - - audio - .arrayBuffer() - .then((arrayBuffer) => { - const pcm = new Uint8Array(arrayBuffer); - const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000; - - // Native AudioEngine expects 16kHz PCM16 - const pcm16k = resamplePcm16(pcm, inputRate, 16000); - const durationSec = pcm16k.length / 2 / 16000; - - native.resumePlayback(); - native.playPCMData(pcm16k); - - clearPlaybackTimeout(); - refs.playbackTimeout = setTimeout(() => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (!active || active.settled) { - return; - } - active.settled = true; - refs.activePlayback = null; - resolve(durationSec); - }, durationSec * 1000); - return undefined; - }) - .catch((error: unknown) => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (active && !active.settled) { - active.settled = true; - refs.activePlayback = null; - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }); - } - - async function processQueue(): Promise { - if (refs.processingQueue || refs.queue.length === 0) { - return; - } - - refs.processingQueue = true; - while (refs.queue.length > 0) { - const item = refs.queue.shift()!; - try { - const duration = await playAudio(item.audio); - item.resolve(duration); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); - } - } - refs.processingQueue = false; - releaseSessionIfIdle(); - } - - return { - async initialize() { - await ensureInitialized(); - }, - - async destroy() { - if (refs.destroyed) { - return; - } - refs.destroyed = true; - this.stop(); - this.clearQueue(); - if (refs.captureActive) { - native.toggleRecording(false); - refs.captureActive = false; - } - clearPlaybackTimeout(); - refs.muted = false; - callbacks.onVolumeLevel(0); - if (refs.initialized) { - native.tearDown(); - refs.initialized = false; - } - microphoneSubscription.remove(); - volumeSubscription.remove(); - interruptionSubscription.remove(); - }, - - async startCapture() { - if (refs.captureActive) { - return; - } - - try { - await ensureMicrophonePermission(); - await ensureInitialized(); - const isRecording = native.toggleRecording(true); - if (!isRecording) { - throw new Error( - "Microphone capture could not start because Android audio focus is unavailable.", - ); - } - refs.captureActive = true; - } catch (error) { - const wrapped = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(wrapped); - throw wrapped; - } - }, - - async stopCapture() { - if (refs.captureActive) { - native.toggleRecording(false); - } - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - releaseSessionIfIdle(); - }, - - toggleMute() { - refs.muted = !refs.muted; - if (refs.muted) { - callbacks.onVolumeLevel(0); - } - return refs.muted; - }, - - isMuted() { - return refs.muted; - }, - - async play(audio: AudioPlaybackSource) { - return await new Promise((resolve, reject) => { - refs.queue.push({ audio, resolve, reject }); - if (!refs.processingQueue) { - void processQueue(); - } - }); - }, - - stop() { - native.stopPlayback(); - clearPlaybackTimeout(); - const active = refs.activePlayback; - refs.activePlayback = null; - if (active && !active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - releaseSessionIfIdle(); - }, - - clearQueue() { - while (refs.queue.length > 0) { - refs.queue.shift()!.reject(new Error("Playback stopped")); - } - refs.processingQueue = false; - releaseSessionIfIdle(); - }, - - isPlaying() { - return refs.activePlayback !== null; - }, - }; -} diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts index eb0e8c88f3..189140c7f6 100644 --- a/packages/app/src/voice/voice-runtime.test.ts +++ b/packages/app/src/voice/voice-runtime.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime"; import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; @@ -307,8 +307,9 @@ describe("voice runtime", () => { runtime.onServerSpeechStateChanged("server-1", true); - expect(engine.stop).toHaveBeenCalledTimes(1); - expect(engine.clearQueue).toHaveBeenCalledTimes(1); + expect(engine.stop).not.toHaveBeenCalled(); + expect(engine.clearQueue).not.toHaveBeenCalled(); + expect(vi.mocked(engine.play).mock.calls[0][1]?.aborted).toBe(true); resolvePlay(0.1); }); diff --git a/packages/app/src/voice/voice-runtime.ts b/packages/app/src/voice/voice-runtime.ts index a8281acbc3..3b7843e2f7 100644 --- a/packages/app/src/voice/voice-runtime.ts +++ b/packages/app/src/voice/voice-runtime.ts @@ -2,7 +2,7 @@ import { Buffer } from "buffer"; import type { AgentStreamEventPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { THINKING_TONE_NATIVE_PCM_BASE64, THINKING_TONE_NATIVE_PCM_DURATION_MS, @@ -118,10 +118,8 @@ interface RuntimePlaybackState { } interface CueState { - active: boolean; - token: number; + controller: AbortController | null; timeout: ReturnType | null; - playing: boolean; } const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = { @@ -210,10 +208,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { generation: 0, }; const cue: CueState = { - active: false, - token: 0, + controller: null, timeout: null, - playing: false, }; const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64")); const cueSource = { @@ -442,18 +438,12 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { } function stopCue(): void { - const hadActive = cue.active || cue.timeout !== null || cue.playing; - cue.active = false; - cue.token += 1; + cue.controller?.abort(); + cue.controller = null; if (cue.timeout) { clearTimeout(cue.timeout); cue.timeout = null; } - cue.playing = false; - if (hadActive) { - deps.engine.stop(); - deps.engine.clearQueue(); - } } function resetCaptureTelemetry(): void { @@ -467,29 +457,26 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { stopCue(); return; } - if (cue.active) { + if (cue.controller) { return; } - cue.active = true; - cue.token += 1; - const token = cue.token; + const controller = new AbortController(); + cue.controller = controller; const playNext = () => { - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } - cue.playing = true; void deps.engine - .play(cueSource) + .play(cueSource, controller.signal) .catch((error) => { - if (cue.token !== token) { + if (controller.signal.aborted) { return; } console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error); }) .finally(() => { - cue.playing = false; - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } cue.timeout = setTimeout( @@ -920,10 +907,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (isSpeaking) { const shouldInterruptPlayback = state.snapshot.phase === "playing" || playback.groups.size > 0; - const hadCue = cue.active || cue.timeout !== null || cue.playing; resetPlaybackState(); stopCue(); - if (shouldInterruptPlayback && !hadCue) { + if (shouldInterruptPlayback) { deps.engine.stop(); deps.engine.clearQueue(); } diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 239b3580ee..5bc82d8a58 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -131,6 +131,8 @@ interface PluginClientContextAliases { } export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + /** Play a base64-encoded audio file on this client; resolves when playback ends. */ + playAudio(source: { base64: string; mimeType: string }): Promise; addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addScreen(contribution: PluginScreenContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index fc473d99d0..7eece2810c 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -166,6 +166,29 @@ The scaffold's `tsconfig.json` omits the DOM library. Keep DOM globals out of cr components; do not add `/// ` or `"DOM"` to `lib`. `layout.platform` carries the same value as React Native's `Platform.OS` for rendering decisions. +### Play audio + +Call `client.playAudio({ base64, mimeType }): Promise` to play an audio file on the +current client (browser, Electron, iOS, or Android). Pass the base64 file returned by your +plugin RPC; no browser globals or platform checks are needed. + +```ts +const audio = await client.rpc(renderSpeech, { text: "Your agent needs you" }); +// renderSpeech returns { base64: string, mimeType: "audio/wav" }. +await client.playAudio(audio); +``` + +The promise resolves when playback finishes and rejects if the file is invalid, playback +fails, or the plugin unloads. Calls share Paseo's voice playback queue and play in order. +Unloading a plugin cancels its active and queued audio. Voice playback controls can also +interrupt that shared queue. Playback does not request microphone permission. + +Use PCM WAV or MP3 for portable files. Other codecs depend on the client's decoder. +MIME parameters are accepted. Pass a complete audio file; raw PCM samples are not supported. +Browsers require user interaction before allowing sound; handle rejection and offer a +play button. The function plays on the device running the plugin client, not on the daemon, +and does not promise delivery while the app is suspended or closed. + ### External links and workspace browsers Use `ExternalLink` to open documentation outside Paseo: From 0d05584d044f5a72212d509ead7af0532dd105e2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:23:11 +0200 Subject: [PATCH 016/281] fix(omp): open agent history that contains a failed browser tool call (#5985) * fix(omp): load history that contains a failed Paseo browser tool result Paseo browser tools return a structured { code, message, retryable } error in the tool result details. The OMP result schema passed details.error through unvalidated while the history mapper treated it as a string, so one failed browser call threw and the whole agent timeline failed to load. Closes #5949 * fix(omp): keep the browser tool's structured error in the tool result Model details.error as a string or a structured { message, ... } error instead of dropping non-string values, so the expanded tool row keeps the error code and retryable flag, and read the failure text from the structured message. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../providers/omp/history-mapper.test.ts | 73 +++++++++++++++++++ .../agent/providers/omp/tool-call-detail.ts | 20 ++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 9a6d9a5b82..02edb43af6 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -54,6 +54,79 @@ describe("OMP history mapper", () => { }); }); + test("replays a Paseo browser tool result whose details error is an object", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "shot-1", name: "browser_screenshot", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "shot-1", + toolName: "browser_screenshot", + content: [{ type: "text", text: "The tab has not painted yet. Retry the screenshot." }], + details: { + ok: false, + error: { + code: "screenshot_no_frame", + message: "The tab has not painted yet. Retry the screenshot.", + retryable: true, + }, + }, + isError: false, + }, + { role: "assistant", content: [{ type: "text", text: "Retrying later." }] }, + ]); + + expect(events.map((event) => event.item)).toEqual([ + expect.objectContaining({ type: "tool_call", callId: "shot-1", status: "running" }), + expect.objectContaining({ + type: "tool_call", + callId: "shot-1", + name: "browser_screenshot", + detail: expect.objectContaining({ + type: "unknown", + output: expect.objectContaining({ + details: expect.objectContaining({ + error: expect.objectContaining({ code: "screenshot_no_frame", retryable: true }), + }), + }), + }), + }), + expect.objectContaining({ type: "assistant_message", text: "Retrying later." }), + ]); + }); + + test("reports a failed tool's structured details error by its message", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "tabs-1", name: "browser_list_tabs", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "tabs-1", + toolName: "browser_list_tabs", + content: [], + details: { + ok: false, + error: { + code: "browser_no_host", + message: "No browser automation host is connected.", + retryable: true, + }, + }, + isError: true, + }, + ]); + + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + status: "failed", + error: "No browser automation host is connected.", + }); + }); + test("restores blocked and abandoned todo state from a session file", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-todo-state-history-")); const sessionFile = join(dir, "session.jsonl"); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts index f0e9d15532..cece7f0398 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts @@ -57,9 +57,13 @@ interface OmpToolResultObject { details?: OmpToolResultDetails; } +interface OmpToolResultStructuredError { + message: string; +} + interface OmpToolResultDetails { diff?: string; - error?: string; + error?: string | OmpToolResultStructuredError; mode?: string; xdev?: unknown; } @@ -155,9 +159,17 @@ const OmpToolResultContentSchema = z.union([ OmpToolResultUnknownContentSchema, ]); +// OMP's own tools report a string error; Paseo host tools (browser_*) report +// a structured `{ code, message, retryable }` error. +const OmpToolResultErrorSchema = z.union([ + z.string(), + z.object({ message: z.string() }).passthrough(), +]); + const OmpToolResultDetailsSchema = z .object({ diff: z.string().optional(), + error: OmpToolResultErrorSchema.optional(), }) .passthrough(); @@ -272,8 +284,10 @@ export function extractTextFromToolResult(result: OmpToolResult): string | undef } export function toolFailureMessage(result: OmpToolResult): string { - if (result && typeof result !== "string" && result.details?.error) { - return result.details.error.split("\n", 1)[0].slice(0, 240); + const error = result && typeof result !== "string" ? result.details?.error : undefined; + const errorMessage = typeof error === "string" ? error : error?.message; + if (errorMessage) { + return errorMessage.split("\n", 1)[0].slice(0, 240); } const output = extractTextFromToolResult(result); const exitMessage = output?.match(/(?:Command|Process) exited with code \d+/i)?.[0]; From 749fe3e5ae52b6e1e4eaeadce3182e901caa96d9 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:04:28 +0200 Subject: [PATCH 017/281] fix(server): keep the local speech library path out of agent environments (#5987) The supervisor prepended the sherpa-onnx platform package directory to the daemon worker's loader variable (PATH, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH), so every agent and terminal inherited it. In packaged desktop builds that directory resolves inside app.asar, and on Windows Git Bash truncates the PATH it hands to native executables at that entry. The local speech worker already sets the loader variable on its own environment when it is forked, so the daemon worker does not need it. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: hanhnt2 <56497031+hanh9898@users.noreply.github.com> --- .../scripts/supervisor-entrypoint.env.test.ts | 85 +++++++++++++++++++ .../server/scripts/supervisor-entrypoint.ts | 3 - 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/server/scripts/supervisor-entrypoint.env.test.ts diff --git a/packages/server/scripts/supervisor-entrypoint.env.test.ts b/packages/server/scripts/supervisor-entrypoint.env.test.ts new file mode 100644 index 0000000000..d87bc678e8 --- /dev/null +++ b/packages/server/scripts/supervisor-entrypoint.env.test.ts @@ -0,0 +1,85 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { + resolveSherpaLoaderEnv, + sherpaLoaderEnvKey, +} from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +const loaderKey = sherpaLoaderEnvKey(); +const loaderEnv = resolveSherpaLoaderEnv(); + +// Agents and terminals inherit the daemon worker's environment, so the local speech +// runtime's library directory must stay scoped to the speech worker. +test.runIf(loaderKey && loaderEnv)( + "daemon worker environment does not carry the local speech library directory", + async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-env-")); + const reportPath = path.join(root, "worker-env.json"); + const preloadPath = path.join(root, "report-worker-env.mjs"); + await writeFile( + preloadPath, + ` + import { writeFileSync } from "node:fs"; + if (/daemon-worker\\.(ts|js)$/.test(process.argv[1] ?? "")) { + writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ value: process.env[${JSON.stringify(loaderKey)}] ?? "" })); + process.exit(0); + } + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + // Start from a parent environment that does not already list the speech library directory. + const envLoaderKey = + Object.keys(env).find((key) => key.toLowerCase() === loaderKey!.toLowerCase()) ?? loaderKey!; + const parentValue = (env[envLoaderKey] ?? "") + .split(path.delimiter) + .filter((entry) => entry && entry !== loaderEnv!.libDir) + .join(path.delimiter); + env[envLoaderKey] = parentValue; + const child = spawn( + process.execPath, + ["--import", "tsx", fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url))], + { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { + ...env, + HOME: root, + USERPROFILE: root, + PASEO_HOME: path.join(root, "home"), + NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + await expect + .poll(async () => readFile(reportPath, "utf8").catch(() => null), { + timeout: 20_000, + message: output, + }) + .not.toBeNull(); + const report = JSON.parse(await readFile(reportPath, "utf8")) as { value: string }; + expect(report.value).toBe(parentValue); + } finally { + if (child.exitCode === null) { + const closed = new Promise((resolve) => child.once("close", resolve)); + child.kill("SIGKILL"); + await closed; + } + await rm(root, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index 6a27b5d2f6..ee3746accf 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -14,7 +14,6 @@ import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; -import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; process.title = "Paseo Supervisor"; @@ -108,8 +107,6 @@ async function main(): Promise { ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) : null; - applySherpaLoaderEnv(workerEnv); - const paseoHome = resolvePaseoHome(workerEnv); const persistedConfig = loadPersistedConfig(paseoHome); const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv); From c7759713ec9649bf971765d101aed4a767e84946 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:58:17 +0200 Subject: [PATCH 018/281] fix(server): publish workspace script status to every client (#6007) Starting or stopping a script from one client sent script_status_update only to that client, so other clients viewing the workspace kept the old status until they reloaded. Script status is daemon state, so the WebSocket server now publishes it to every session, and each session delivers it only to its script_status_update subscribers. Scripts started over MCP use the same path. Closes #5856 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Scott Woolsey Biggart <297695+swbiggart@users.noreply.github.com> --- packages/server/src/server/bootstrap.ts | 3 +- .../terminal-workspace-sdk.e2e.test.ts | 52 +++++++++++++++++++ packages/server/src/server/session.ts | 8 ++- .../workspace-scripts-service.test.ts | 21 +++++--- .../workspace-scripts-service.ts | 10 ++-- .../server/src/server/websocket-server.ts | 8 +++ 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3c2428c2dc..0166d460f4 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1417,9 +1417,8 @@ export async function createPaseoDaemon( serviceProxyPublicBaseUrl, resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), logger, - // MCP operations do not belong to one WebSocket session, so lifecycle - // status updates fan out to every connected client. emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)), + publishStatusUpdate: (message) => wsServer?.publishScriptStatusUpdate(message), spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 9c27d2039c..19ccdf45e8 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -192,3 +192,55 @@ test("listing by workspace ID keeps terminals in a shared directory separate", a expect.objectContaining({ id: created.terminal?.id, workspaceId: second, cwd }), ]); }); + +test("script status started and stopped by one client reaches every subscribed client", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { sleeper: { type: "script", command: 'node -e "setInterval(() => {}, 60000)"' } }, + }), + ); + const observer = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const bystander = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await observer.connect(); + await bystander.connect(); + const bystanderEvents: string[] = []; + bystander.subscribeRawMessages((message) => bystanderEvents.push(message.type)); + const opened = await client.openProject(cwd); + expect(opened.error).toBeNull(); + const workspaceId = opened.workspace!.id; + const actorSeen = await observeScriptLifecycles(client, "sleeper"); + const observerSeen = await observeScriptLifecycles(observer, "sleeper"); + + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("running"); + await expect.poll(() => observerSeen.at(-1)).toBe("running"); + + expect((await client.stopWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("stopped"); + await expect.poll(() => observerSeen.at(-1)).toBe("stopped"); + expect(bystanderEvents).not.toContain("script_status_update"); + } finally { + await observer.close(); + await bystander.close(); + } +}, 60_000); + +async function observeScriptLifecycles( + daemonClient: DaemonClient, + scriptName: string, +): Promise { + const lifecycles: string[] = []; + const subscription = daemonClient.observeEvents(["script_status_update"]); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "script_status_update") return; + const script = message.payload.scripts.find((entry) => entry.scriptName === scriptName); + if (script) lifecycles.push(script.lifecycle); + }, + }); + await subscription.ready; + return lifecycles; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1307cf808f..4e57ee0578 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -26,6 +26,7 @@ import { type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, + type ScriptStatusUpdateMessage, type GitSetupOptions, type StartWorkspaceScriptRequest, type WorkspaceScriptListRequest, @@ -450,6 +451,7 @@ export interface SessionOptions { getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; + publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void; logger: pino.Logger; downloadTokenStore: DownloadTokenStore; pushNotifications: PushNotifications; @@ -810,6 +812,7 @@ export class Session { getTransportBufferedAmount, onLifecycleIntent, onWorkspaceRecovered, + publishScriptStatusUpdate, logger, downloadTokenStore, pushNotifications, @@ -1148,8 +1151,11 @@ export class Session { resolveScriptHealth: this.resolveScriptHealth, logger: this.sessionLogger, emit: (message) => this.emit(message), + publishStatusUpdate: (message) => { + if (publishScriptStatusUpdate) publishScriptStatusUpdate(message); + else this.emit(message); + }, spawnWorkspaceScript, - wantsStatusUpdates: () => this.wantsEvent("script_status_update"), assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 0ba8a0169b..aa366a3495 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pino } from "pino"; import { afterEach, describe, expect, test } from "vitest"; -import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js"; +import type { + ScriptStatusUpdateMessage, + SessionOutboundMessage, + StartWorkspaceScriptRequest, +} from "../../messages.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { @@ -82,6 +86,7 @@ interface BuildOptions { function buildService(options: BuildOptions = {}) { const emitted: SessionOutboundMessage[] = []; + const published: ScriptStatusUpdateMessage[] = []; const spawnCalls: SpawnWorkspaceScriptOptions[] = []; const workspace = options.workspace === undefined @@ -108,6 +113,7 @@ function buildService(options: BuildOptions = {}) { resolveScriptHealth: null, logger, emit: (message) => emitted.push(message), + publishStatusUpdate: (message) => published.push(message), async spawnWorkspaceScript(spawnOptions): Promise { spawnCalls.push(spawnOptions); if (options.spawnThrows) { @@ -126,7 +132,7 @@ function buildService(options: BuildOptions = {}) { }, }); - return { service, emitted, spawnCalls }; + return { service, emitted, published, spawnCalls }; } const request: StartWorkspaceScriptRequest = { @@ -215,10 +221,11 @@ describe("buildSnapshot", () => { }); describe("emitStatusUpdate", () => { - test("emits one script_status_update carrying the snapshot", async () => { - const { service, emitted } = buildService(); + test("publishes one script_status_update carrying the snapshot", async () => { + const { service, emitted, published } = buildService(); await service.emitStatusUpdate("ws-1", "/tmp/repo"); - expect(emitted).toEqual([ + expect(emitted).toEqual([]); + expect(published).toEqual([ { type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } }, ]); }); @@ -332,7 +339,7 @@ describe("start", () => { }); test("spawns the script with resolved git metadata and reports success", async () => { - const { service, emitted, spawnCalls } = buildService(); + const { service, emitted, published, spawnCalls } = buildService(); await service.start(request); expect(spawnCalls).toHaveLength(1); @@ -345,7 +352,7 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(emitted).toContainEqual({ + expect(published).toContainEqual({ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] }, }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 2116e82b11..d449a48855 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,5 +1,6 @@ import type pino from "pino"; import type { + ScriptStatusUpdateMessage, SessionOutboundMessage, StartWorkspaceScriptRequest, WorkspaceDescriptorPayload, @@ -31,7 +32,7 @@ type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; /** * The service-proxy-backed scripts a workspace exposes: build the scripts payload - * snapshot, emit a script_status_update to clients, and start a script. + * snapshot, publish a script_status_update to every client, and start a script. * * The workspace descriptor builder, the script-status emission path, and the * start-script RPC all funnel through one assembly of buildWorkspaceScriptPayloads' @@ -66,7 +67,8 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; - wantsStatusUpdates?: () => boolean; + // Script status is daemon state, so it reaches every client, not only the requester. + publishStatusUpdate: (message: ScriptStatusUpdateMessage) => void; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; assertAutomationAllowed: (workspaceId: string) => Promise; }): WorkspaceScriptsService { @@ -84,6 +86,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts, logger, emit, + publishStatusUpdate, spawnWorkspaceScript, assertAutomationAllowed, } = deps; @@ -130,12 +133,11 @@ export function createWorkspaceScriptsService(deps: { } async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { - if (deps.wantsStatusUpdates && !deps.wantsStatusUpdates()) return; try { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; const project = await projectRegistry.get(workspace.projectId); - emit({ + publishStatusUpdate({ type: "script_status_update", payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 67c78184fb..4dcdc680ab 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -20,6 +20,7 @@ import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-m import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, + type ScriptStatusUpdateMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, type WSHelloMessage, @@ -978,6 +979,12 @@ export class VoiceAssistantWebSocketServer { } } + public publishScriptStatusUpdate(message: ScriptStatusUpdateMessage): void { + for (const session of this.listSessions()) { + session.emitServerMessage(message); + } + } + public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { this.updateServerCapabilities(buildServerCapabilities({ readiness })); } @@ -1465,6 +1472,7 @@ export class VoiceAssistantWebSocketServer { ), ); }, + publishScriptStatusUpdate: (message) => this.publishScriptStatusUpdate(message), downloadTokenStore: this.downloadTokenStore, pushNotifications: this.pushNotifications, paseoHome: this.paseoHome, From 5eb4c8afad31edbe6cb3f55754f2907955d7c958 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 23:12:57 +0100 Subject: [PATCH 019/281] feat(usage): show each agent's own plan usage in its context window details (#5975) * feat(usage): stream reports for live agent accounts Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately. * fix(usage): retain account scope for open agent sessions Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract. * test(usage): update browser fixtures for scoped report streams * feat(usage): let a usage card show window rows without pins UsageCard takes a required pinnable prop. When false, a window row is a plain bar: no Pressable, hover highlight, or pin glyph. The settings Usage list passes true. * feat(usage): show the agent's usage in the context window popover Reports stream into the query cache as each update arrives, for the host list and for a new per-agent query keyed by agent. The context window popover fetches its agent's reports while open and renders one unpinnable card per report as it lands, a loading sentence before the first, the request error as a sentence, and nothing on hosts without usage reports. * feat(usage): hide the Refresh button on popover usage cards UsageCard takes a required refreshable prop. The popover passes false because its content takes no pointer events, so the button could not be pressed; the card then shows its freshness inline. The settings Usage list keeps the button. * feat(usage): make the sidebar Usage summary opt-in The sidebar Usage item starts hidden on every layout, not only on compact ones, so resolveSidebarNavItems drops its compact input. While the summary is off, the Usage list renders window rows without pins; saved pins stay in preferences and return when it is turned on. Specs that read the summary now turn it on first. * docs(glossary): describe the popover usage card and the opt-in sidebar summary * fix(usage): show a popover request error without retrying A daemon error for an agent's usage, such as an unknown agent, does not heal on retry, and reopening the popover fetches again. Retrying held the loading sentence for about seven seconds before the error. * test(usage): cover agent usage in the context window popover The usage fixture records agentId, streams reports held behind promises, and can switch host support between reloads. The meter spec walks loading, streaming, a report problem, a request error and a host without usage reports, on desktop and compact in both themes. * fix(usage): keep the plain context window tooltip on hosts without usage The popover widened to fit usage cards on every host. Hosts that cannot report usage now keep the tooltip's default padding and width, as before, and mount no usage section. * fix(usage): let a report's Refresh replace it in the agent's list too Refresh wrote the refreshed report only into the host's list, so a card rendered from an agent's list kept the old report. Both lists now sit under one host key and Refresh replaces the report in each that holds it. * feat(usage): open the context window details in a sheet on compact screens Wide screens keep the tooltip. On compact screens pressing the meter opens a content-sized bottom sheet titled Context window. Both render one ContextWindowDetails body; only the sheet's usage cards have Refresh, since the tooltip cannot be pressed. Pins stay off in both. * test(usage): cover the compact context window sheet On the compact viewport the meter opens the sheet; its cards have Refresh, a Refresh replaces the card, and Close dismisses the sheet. The glossary names the sheet. * feat(ui): give tooltips and the context window sheet accessible roles Tooltip content is role=tooltip, and the context window sheet is a dialog named Context window, so assistive tech and tests find them by role. The agent usage message drops its test ID; it is found by its text. * test(usage): reshape the context window and Usage switch specs as journeys The context window spec runs one tooltip journey and one sheet journey per theme, with no layout branches. Its fixture script, meter actions and report builders move to a context-window helper, and it finds the meter, tooltip, sheet and Refresh buttons by role. The theme comes from the system colour scheme. The Usage switch test uses named helpers for the Usage icon and the Summary in sidebar switch. * fix(usage): say an agent's usage request failed instead of showing older reports A failed request keeps whatever reports the query held: those from an earlier open, or those that streamed in before the failure. The context window details showed them as if they were the agent's current usage. The request error now wins over held reports. * test(usage): let a fixture clean up the context window spec's agent The tooltip and sheet tests no longer wrap their steps in try/finally; a spec-local fixture seeds the agent and removes it after the test. * fix(usage): keep a refreshed report when the list it is in finishes Pressing Refresh on one card while another source was still streaming put the pre-refresh report back: the finished list replaced the query's data with the copies the request had fetched when it started. A report now keeps whichever copy was fetched later, both when a streamed copy arrives and when the finished list replaces the streamed one. * fix(usage): show only the login a resumed agent runs under now An agent resumed under another login keeps its ID, so its usage query kept the previous login's reports: reopening the context window showed them while the new login's reports loaded, then both side by side until the request finished. The agent's usage is now dropped as soon as its tooltip or sheet closes, so each open loads the login the agent runs under at that moment. * fix(usage): stop a closed agent usage request from writing reports Closing the context window details cancels the agent's usage request and drops reports still on their way, so reopening after a resume under another login sends a new request instead of joining the old one. --- docs/glossary.md | 4 +- docs/plugins.md | 2 +- docs/rpc-namespacing.md | 8 +- .../e2e/browser/context-window-meter.spec.ts | 218 +++++++++++++++++- .../e2e/browser/plugin-sidebar-items.spec.ts | 4 +- .../e2e/browser/sidebar-nav-settings.spec.ts | 6 +- packages/app/e2e/browser/usage-screen.spec.ts | 6 + .../e2e/browser/usage-sidebar-item.spec.ts | 76 ++++-- .../app/e2e/support/helpers/context-window.ts | 166 +++++++++++++ .../support/helpers/sidebar-nav-settings.ts | 17 +- .../app/e2e/support/helpers/usage-login.ts | 2 +- .../app/e2e/support/helpers/usage-reports.ts | 71 ++++-- .../e2e/support/helpers/usage-sidebar-item.ts | 35 +++ .../src/components/context-window-details.tsx | 74 ++++++ .../src/components/context-window-meter.tsx | 179 ++++++++------ .../src/components/context-window-sheet.tsx | 80 +++++++ packages/app/src/components/ui/tooltip.tsx | 2 + packages/app/src/composer/index.tsx | 8 + packages/app/src/sidebar-nav/model.test.ts | 44 +--- packages/app/src/sidebar-nav/model.ts | 15 +- .../src/sidebar-nav/use-sidebar-nav-items.ts | 12 +- packages/app/src/usage/agent-usage.tsx | 66 ++++++ packages/app/src/usage/card.tsx | 37 ++- packages/app/src/usage/copy.ts | 1 + packages/app/src/usage/display.ts | 17 +- packages/app/src/usage/in-sidebar.ts | 19 ++ packages/app/src/usage/index.ts | 2 + packages/app/src/usage/list.tsx | 12 +- packages/app/src/usage/model.test.ts | 93 ++++++++ packages/app/src/usage/model.ts | 59 +++++ packages/app/src/usage/options.tsx | 11 +- packages/app/src/usage/queries.ts | 114 ++++++++- packages/app/src/usage/window-bar.tsx | 34 ++- packages/client/src/daemon-client.test.ts | 87 ++++++- packages/client/src/daemon-client.ts | 71 ++++-- packages/plugin/src/server/usage.ts | 17 +- packages/protocol/src/messages.ts | 8 +- .../src/server/agent/agent-manager.test.ts | 30 +++ .../server/src/server/agent/agent-manager.ts | 5 + .../src/server/agent/agent-sdk-types.ts | 8 + .../src/server/agent/provider-registry.ts | 1 + .../agent/providers/claude/agent.env.test.ts | 9 + .../server/agent/providers/claude/agent.ts | 21 +- .../providers/codex-app-server-agent.test.ts | 28 +++ .../agent/providers/codex-app-server-agent.ts | 43 +++- .../server/agent/providers/omp/agent.test.ts | 3 + .../src/server/agent/providers/omp/agent.ts | 13 ++ .../agent/providers/omp/cli-runtime.test.ts | 11 +- .../server/agent/providers/omp/cli-runtime.ts | 9 +- .../src/server/agent/providers/omp/runtime.ts | 1 + .../providers/omp/test-utils/fake-omp.ts | 2 + .../providers/omp/test-utils/omp-harness.ts | 4 + .../agent/providers/opencode-agent.test.ts | 25 ++ .../server/agent/providers/opencode-agent.ts | 26 ++- .../providers/opencode-server-manager.test.ts | 1 + .../providers/opencode/server-manager.ts | 17 +- .../providers/opencode/test-server-manager.ts | 1 + .../test-utils/test-opencode-harness.ts | 1 + .../server/agent/providers/pi/agent.test.ts | 8 +- .../src/server/agent/providers/pi/agent.ts | 13 ++ .../agent/providers/pi/cli-runtime.test.ts | 10 +- .../server/agent/providers/pi/cli-runtime.ts | 9 +- .../src/server/agent/providers/pi/runtime.ts | 1 + .../agent/providers/pi/test-utils/fake-pi.ts | 2 + .../authorization/operation-permissions.ts | 1 + packages/server/src/server/bootstrap.ts | 6 +- packages/server/src/server/plugins/index.ts | 20 +- .../server/plugins/plugin-process-protocol.ts | 10 +- .../src/server/plugins/plugin-process.ts | 3 +- packages/server/src/server/plugins/runtime.ts | 10 +- .../server/plugins/usage-sources.e2e.test.ts | 9 +- .../plugins/usage-sources/index.test.ts | 98 +++++++- .../src/server/plugins/usage-sources/index.ts | 154 +++++++++++-- .../session/owned-subscriptions/replies.ts | 1 + .../session/usage/usage-session.test.ts | 21 +- .../src/server/session/usage/usage-session.ts | 40 ++-- plugins/claude-usage-source/index.server.ts | 2 +- .../server/keychain.test.ts | 1 + .../claude-usage-source/server/stores.test.ts | 20 +- .../claude-usage-source/server/usage.test.ts | 122 ++++++++-- plugins/claude-usage-source/server/usage.ts | 91 ++++++-- plugins/claude-usage-source/shared/input.ts | 8 +- plugins/codex-usage-source/index.server.ts | 2 +- .../codex-usage-source/server/stores.test.ts | 6 +- .../codex-usage-source/server/usage.test.ts | 55 ++++- plugins/codex-usage-source/server/usage.ts | 56 ++++- plugins/copilot-usage-source/index.server.ts | 2 +- plugins/cursor-usage-source/index.server.ts | 2 +- plugins/grok-usage-source/index.server.ts | 2 +- plugins/kimi-usage-source/index.server.ts | 2 +- plugins/minimax-usage-source/index.server.ts | 2 +- .../opencode-go-usage-source/index.server.ts | 2 +- plugins/zai-usage-source/index.server.ts | 2 +- public-docs/plugins/reference.md | 32 ++- 94 files changed, 2359 insertions(+), 402 deletions(-) create mode 100644 packages/app/e2e/support/helpers/context-window.ts create mode 100644 packages/app/src/components/context-window-details.tsx create mode 100644 packages/app/src/components/context-window-sheet.tsx create mode 100644 packages/app/src/usage/agent-usage.tsx create mode 100644 packages/app/src/usage/in-sidebar.ts diff --git a/docs/glossary.md b/docs/glossary.md index 67730885fb..390acefa19 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`). -- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index e0d94de357..1093381c61 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -483,7 +483,7 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. 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. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md index 5ddb7220e4..627a227eb5 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request; // -> checkout.forge.set_auto_merge.response ``` -Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema. +A finite streaming request can emit `.update` messages before its terminal `.response`. +`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and +`.response` carries the request-level error or `null`. All three share `requestId`; no updates +follow the response. An empty successful stream is an empty list. Register updates as replies +of the originating request so source-owned delivery routes them to the requesting connection. + +Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema. ## Message Shape diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index c7868ac94e..7905d2c1cd 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,7 +1,40 @@ -import type { Locator } from "@playwright/test"; -import { expect, test } from "../support/fixtures"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test as base } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; +import { + type AgentUsageScript, + closeContextWindowSheet, + contextWindowSheet, + expectRefreshButtons, + expiredLogin, + gate, + hoverContextWindowMeter, + leaveContextWindowMeter, + type MockAgentSession, + onPersonalLogin, + onWorkLogin, + openAgent, + pressContextWindowMeter, + qaScreenshot, + refreshUsageCard, + reloadAgent, + scriptAgentUsage, + seedAgentWithContextWindow, + usageCard, +} from "../support/helpers/context-window"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item"; + +const test = base.extend<{ agent: MockAgentSession }>({ + agent: async ({ page: _page }, provide) => { + const agent = await seedAgentWithContextWindow(); + try { + await provide(agent); + } finally { + await agent.cleanup(); + } + }, +}); // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none. @@ -69,3 +102,184 @@ test.describe("context window meter", () => { } }); }); + +const DESKTOP = { width: 1440, height: 900 }; +const COMPACT = { width: 390, height: 844 }; +const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/; + +type OpenDetails = (page: Page) => Promise; +type Shot = (state: string) => Promise; + +/** Two reports on the agent's login stream in; the details show each card as it arrives. */ +async function expectCardsToStreamIn( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + const [claude, codex] = claudeAndCodexReports(); + const first = gate(); + const second = gate(); + usage.answerNext({ + stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)], + }); + const details = await openDetails(page); + await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible(); + await shot("loading"); + + first.open(); + await expect( + usageCard(details, "claude:work").getByText("work@example.com", { exact: true }), + ).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(usageCard(details, "codex:work")).toHaveCount(0); + await shot("streaming"); + + second.open(); + await expect( + usageCard(details, "codex:work").getByText("Session", { exact: true }), + ).toBeVisible(); + // Only the agent's login, never the host's default one, and no pins. + await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0); + await expectUnpinnableRows(details); + await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2); + return details; +} + +async function expectFailedRequestSentence( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.answerNext({ error: "Unknown agent" }); + await reloadAgent(page); + const details = await openDetails(page); + await expect( + details.getByText("Unable to load usage: Unknown agent", { exact: true }), + ).toBeVisible(); + await shot("error"); +} + +async function expectOnlyContextWindowWithoutUsage( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.stopReportingUsage(); + await reloadAgent(page); + const details = await openDetails(page); + await expect(details.getByText("25% used", { exact: true })).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0); + await shot("unsupported"); +} + +for (const theme of ["light", "dark"] as const) { + test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`); + + await test.step("reports stream in one card at a time, without Refresh", async () => { + const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + // The tooltip cannot be pressed, so its cards have no Refresh. + await expectRefreshButtons(tooltip, 0); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); + }); + + await test.step("after a resume under another login, only that login shows", async () => { + // Closed while the old login's report is still on its way. + await leaveContextWindowMeter(page); + const slowSource = gate(); + usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] }); + const closedEarly = await hoverContextWindowMeter(page); + await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible(); + await leaveContextWindowMeter(page); + + const resumed = gate(); + const finished = gate(); + usage.answerNext({ + stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], + }); + const tooltip = await hoverContextWindowMeter(page); + await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible(); + await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + + // The old login's report lands during the new request, before the new login's. + slowSource.open(); + resumed.open(); + await expect( + usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }), + ).toBeVisible(); + await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + finished.open(); + }); + + await test.step("a report with a problem shows it on the card", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + await reloadAgent(page); + const tooltip = await hoverContextWindowMeter(page); + await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible(); + await shot("problem"); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); + // One request per open that had usage; this one sends none. + expect(usage.agentRequests()).toHaveLength(5); + }); + }); + + test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({ + page, + agent, + }) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize(COMPACT); + await openAgent(page, agent); + const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`); + + await test.step("reports stream in one card at a time, each with Refresh", async () => { + const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); + await expectRefreshButtons(sheet, 2); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); + }); + + await test.step("Refresh replaces the card with the source's new report", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + const sheet = contextWindowSheet(page); + await refreshUsageCard(sheet, "Claude"); + await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedReports()).toEqual([["claude:work"]]); + await shot("problem"); + }); + + await test.step("Close dismisses the sheet", async () => { + await closeContextWindowSheet(page); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); + expect(usage.agentRequests()).toHaveLength(2); + }); + }); +} diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index 49c781f84d..900fb42db9 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -20,6 +20,7 @@ import { import { leaveSettings, openSidebarNavSettings, + seedSidebarFooterPreferences, setFooterItemVisible, } from "../support/helpers/sidebar-nav-settings"; import { seedWorkspace } from "../support/helpers/seed-client"; @@ -154,8 +155,9 @@ test.describe("Plugin sidebar items", () => { page, }) => { test.setTimeout(180_000); - // The Usage item shows only with summary data. + // The Usage item shows only with summary data, once it is turned on. await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); await page.setViewportSize(WIDE); await gotoWorkspace(page, workspaceId); const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys"); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 499b67e0bf..a17480b740 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -34,7 +34,10 @@ import { test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); - await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]); + await seedSidebarFooterPreferences(page, [ + { key: "add-project", visible: false }, + { key: "usage", visible: true }, + ]); await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); @@ -188,6 +191,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => { await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); // Keys for the fixed footer icon buttons are ignored. await seedSidebarFooterPreferences(page, [ + { key: "usage", visible: true }, { key: "help", visible: false }, { key: "hosts", visible: false }, { key: "import", visible: false }, diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 2ca4e50830..8528f4a0ea 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -8,6 +8,7 @@ import { gotoAppShell } from "../support/helpers/app"; import { addConnectedHostAndReload } from "../support/helpers/hosts"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { getServerId } from "../support/helpers/server-id"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { installUsageReportsFixture, type UsageListRequest, @@ -57,6 +58,11 @@ test.use({ }, }); +// These tests read the sidebar summary and pin windows, which both need the Usage item on. +test.beforeEach(async ({ page }) => { + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); +}); + function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] { return usage.listRequests().filter((request) => request.forceRefresh); } diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index f42b438067..c2da27b401 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -5,6 +5,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; import { + expectFooterItemSetting, expectFooterSeparator, leaveSettings, openSidebarNavSettings, @@ -16,9 +17,13 @@ import { expectNoUsageItem, expectOnUsageScreen, expectPinnedUsage, + expectSummaryInSidebar, + expectUnpinnableRows, leaveUsageScreen, openCompactSidebar, + openUsageScreenFromIcon, pinRow, + setSummaryInSidebar, openUsageOptions, showUsageAs, togglePin, @@ -93,18 +98,15 @@ test.describe("Usage item", () => { await gotoAppShell(page); const screen = page.getByTestId(`usage-host-${serverId}`); - await test.step("a fresh device shows default windows, which opens the Usage screen", async () => { - await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); - await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); + await test.step("a fresh device shows default windows once the Usage item is on", async () => { + // The Usage item starts off on every layout; turning it on stores the choice. + await expectNoUsageItem(page); await openSidebarNavSettings(page); + await expectFooterItemSetting(page, "usage", false); + await setFooterItemVisible(page, "usage", true); await qaScreenshot(page, "desktop-settings-sidebar-footer"); await page.setViewportSize(COMPACT); - // Phones start with the Usage item off; turning it on stores the choice. - await expect( - page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"), - ).toHaveAttribute("aria-checked", "false"); - await setFooterItemVisible(page, "usage", true); + await expectFooterItemSetting(page, "usage", true); await qaScreenshot(page, "compact-settings-sidebar-footer"); await gotoAppShell(page); await openCompactSidebar(page); @@ -112,6 +114,8 @@ test.describe("Usage item", () => { await qaScreenshot(page, "compact-footer-defaults"); await page.setViewportSize(WIDE); await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); await usageItem(page).click(); await expectOnUsageScreen(page); await qaScreenshot(page, "default-pins-card"); @@ -298,26 +302,49 @@ test("without summary data the footer drops the Usage item and keeps the Usage i await expectOnUsageScreen(page); }); -test("the Usage Settings show and hide the sidebar Usage item", async ({ page }) => { +test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({ + page, +}) => { + const serverId = getServerId(); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await page.setViewportSize(WIDE); await gotoAppShell(page); - await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + const screen = page.getByTestId(`usage-host-${serverId}`); + const claude = screen.getByTestId("usage-report-claude:default"); - await openUsageOptions(page); - const toggle = page.getByTestId("usage-show-in-sidebar").getByRole("switch"); - await expect(toggle).toHaveAttribute("aria-checked", "true"); - await toggle.click(); - await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0); + await test.step("off by default: no Usage item, and rows that do not pin", async () => { + await openUsageScreenFromIcon(page); + await expectNoUsageItem(page); + await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expectUnpinnableRows(claude); + await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default")); + await expectSummaryInSidebar(page, false); + await qaScreenshot(page, "usage-screen-summary-off"); + }); - await page.reload(); - await expectOnUsageScreen(page); - await openUsageOptions(page); - await expect(toggle).toHaveAttribute("aria-checked", "false"); - await toggle.click(); - await expect(usageItem(page)).toBeVisible(); + await test.step("on: the Usage item and the pins appear", async () => { + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); + await togglePin(screen, "Claude", "Weekly"); + await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); + await qaScreenshot(page, "usage-screen-summary-on"); + }); + + await test.step("off then on again, after a reload, brings back the saved pins", async () => { + await setSummaryInSidebar(page, false); + await expectNoUsageItem(page); + await expectUnpinnableRows(claude); + await page.reload(); + await expectOnUsageScreen(page); + await expectSummaryInSidebar(page, false); + await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expectUnpinnableRows(claude); + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked(); + }); }); test("released hosts supply source logos through the client conversion", async ({ page }) => { @@ -325,6 +352,7 @@ test("released hosts supply source logos through the client conversion", async ( lists: [() => claudeAndCodexReports()], providerUsageListOnly: true, }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); await page.setViewportSize(WIDE); await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts new file mode 100644 index 0000000000..2b76a78e85 --- /dev/null +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -0,0 +1,166 @@ +import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expectComposerVisible } from "./composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent"; +import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports"; +import { claudeAndCodexReports } from "./usage-sidebar-item"; + +// 32,000 of the mock's 128,000-token window. +const METER_NAME = "Context window 25% used"; + +export type MockAgentSession = Awaited>; + +/** An agent a quarter into its context window. */ +export function seedAgentWithContextWindow(): Promise { + return seedMockAgentWorkspace({ + repoPrefix: "context-window-usage-", + title: "Context window usage e2e", + initialPrompt: "emit 32000 byte file agent stream payload", + }); +} + +export async function openAgent(page: Page, session: MockAgentSession): Promise { + await openAgentRoute(page, session); + await expectComposerVisible(page); +} + +export async function reloadAgent(page: Page): Promise { + await page.reload({ waitUntil: "commit" }); + await expectComposerVisible(page); +} + +/** Wide screens show the meter's details in a tooltip while the pointer is on it. */ +export async function hoverContextWindowMeter(page: Page): Promise { + await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 }); + const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" }); + await expect(tooltip).toBeVisible(); + return tooltip; +} + +export async function leaveContextWindowMeter(page: Page): Promise { + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0); +} + +/** Compact screens open the meter's details in a sheet. */ +export async function pressContextWindowMeter(page: Page): Promise { + await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 }); + const sheet = contextWindowSheet(page); + await expect(sheet).toBeVisible(); + return sheet; +} + +export async function closeContextWindowSheet(page: Page): Promise { + const sheet = contextWindowSheet(page); + await sheet.getByRole("button", { name: "Close", exact: true }).click(); + await expect(sheet).toHaveCount(0); +} + +export function contextWindowSheet(page: Page): Locator { + return page.getByRole("dialog", { name: "Context window" }); +} + +/** One report card; cards have no role of their own. */ +export function usageCard(surface: Locator, reportId: string): Locator { + return surface.getByTestId(`usage-report-${reportId}`); +} + +export async function refreshUsageCard(surface: Locator, source: string): Promise { + await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click(); +} + +export async function expectRefreshButtons(surface: Locator, count: number): Promise { + await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count); +} + +/** A report on the agent's own login, which is not the host's default one. */ +export function onWorkLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "work"); +} + +/** A report on the login an agent resumed under. */ +export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "personal"); +} + +function onLogin(report: UsageReportEntry, login: string): UsageReportEntry { + return { + ...report, + id: `${report.sourceId}:${login}`, + account: { label: `${login}@example.com` }, + }; +} + +export function expiredLogin(report: UsageReportEntry): UsageReportEntry { + return { + ...report, + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + refreshedBy: "claude", + }, + }, + }; +} + +/** Holds a stream at a point until the test opens it, like a slow source. */ +export function gate(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; +} + +export interface AgentUsageScript { + /** The answer to the next agent usage request or report Refresh. */ + answerNext(response: UsageListResponse): void; + /** From the next reload the host no longer reports usage. */ + stopReportingUsage(): void; + /** The agent of each agent usage request, in order. */ + agentRequests(): string[]; + /** Each report Refresh, by report ID. */ + refreshedReports(): string[][]; +} + +/** + * Scripts the host's usage: the host's own list is Claude and Codex on its default login; each + * agent request and each Refresh takes the next scripted answer. + */ +export async function scriptAgentUsage(page: Page): Promise { + let supported = true; + const answers: UsageListResponse[] = []; + const usage = await installUsageReportsFixture(page, { + usageSupported: () => supported, + lists: [ + (request) => { + if (!request.agentId && !request.reportIds) return claudeAndCodexReports(); + const next = answers.shift(); + if (!next) throw new Error("The test scripts every agent usage request."); + return next; + }, + ], + }); + return { + answerNext: (response) => answers.push(response), + stopReportingUsage: () => { + supported = false; + }, + agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), + refreshedReports: () => + usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + }; +} + +/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */ +export async function qaScreenshot(page: Page, name: string): Promise { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + // Let the sheet and tooltip animations settle. + await page.waitForTimeout(600); + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + await page.screenshot({ path: path.join(directory, `${name}.png`) }); +} diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index a8c64f712e..b707abdae8 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -197,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise { .click(); } +function footerItemSwitch(page: Page, key: string): Locator { + return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`); +} + export async function setFooterItemVisible( page: Page, key: string, visible: boolean, ): Promise { - const toggle = page - .getByTestId("sidebar-nav-section-footer") - .getByTestId(`sidebar-nav-toggle-${key}`); + const toggle = footerItemSwitch(page, key); await toggle.click(); await expect(toggle).toHaveAttribute("aria-checked", String(visible)); } +/** Whether Settings > Sidebar shows a footer item as on. */ +export async function expectFooterItemSetting( + page: Page, + key: string, + visible: boolean, +): Promise { + await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible)); +} + export async function expectFooterOrder(page: Page, keys: string[]): Promise { await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows"); } diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 4e83541b91..234ec2bf1b 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -101,7 +101,7 @@ import {fetchUsage, discover} from "./server/usage.js"; import {inputSchema} from "./shared/input.js"; export default function contribute(server) { server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema, - discover: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}), + discover: (scope) => discover(scope, {home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}), fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))), }); return () => {}; diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index b379c27263..86b74c6ea3 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -5,6 +5,7 @@ import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; export interface UsageListRequest { forceRefresh: boolean; reportIds?: string[]; + agentId?: string; } export interface UsageReportsFixture { @@ -19,15 +20,25 @@ interface UsageReportsFixtureOptions { * `fetchedAt`, or a different answer to a forced refresh). */ lists?: Array UsageListResponse)>; - /** False simulates a host with no usage reporting capability. */ - usageSupported?: boolean; + /** + * False simulates a host with no usage reporting capability. A function is read each time the + * app connects, so a reload can switch it. + */ + usageSupported?: boolean | (() => boolean); /** Released hosts expose provider.usage.list with no source icons. */ providerUsageListOnly?: boolean; /** The host daemon's port; defaults to the E2E daemon. */ port?: number; } -type UsageListResponse = UsageReportEntry[] | { error: string }; +/** + * A list of reports, a request error, or a stream: reports sent in order, where a promise holds + * the rest of the stream and the response until it settles, like a slow source. + */ +export type UsageListResponse = + | UsageReportEntry[] + | { error: string } + | { stream: Array> }; type WebSocketMessage = string | Buffer; @@ -114,6 +125,8 @@ export async function installUsageReportsFixture( const listRequests: UsageListRequest[] = []; const listCounter = createCounter(); const usageSupported = options.usageSupported ?? true; + const isUsageSupported = () => + typeof usageSupported === "function" ? usageSupported() : usageSupported; const providerUsageListOnly = options.providerUsageListOnly ?? false; const requestType = providerUsageListOnly ? "provider.usage.list.request" @@ -131,6 +144,7 @@ export async function installUsageReportsFixture( const listRequest: UsageListRequest = { forceRefresh: request.forceRefresh === true, reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, + agentId: typeof request.agentId === "string" ? request.agentId : undefined, }; listRequests.push(listRequest); const scripted = pick(options.lists ?? [[]], listRequests.length - 1); @@ -139,22 +153,51 @@ export async function installUsageReportsFixture( ws.send( JSON.stringify({ type: "session", - message: { - type: "rpc_error", - payload: { - requestId, - requestType, - error: response.error, - code: "transport", - }, - }, + message: providerUsageListOnly + ? { + type: "rpc_error", + payload: { requestId, requestType, error: response.error, code: "transport" }, + } + : { + type: "usage.list_reports.response", + payload: { requestId, error: response.error }, + }, }), ); listCounter.increment(); return; } + const sendUpdate = (report: UsageReportEntry) => + ws.send( + JSON.stringify({ + type: "session", + message: { type: "usage.list_reports.update", payload: { requestId, report } }, + }), + ); + if ("stream" in response) { + void (async () => { + for (const item of response.stream) { + if (item instanceof Promise) await item; + else sendUpdate(item); + } + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "usage.list_reports.response", + payload: { requestId, error: null }, + }, + }), + ); + listCounter.increment(); + })(); + return; + } const ids = Array.isArray(request.reportIds) ? request.reportIds : null; const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response; + if (!providerUsageListOnly) { + for (const report of reports) sendUpdate(report); + } const reply = providerUsageListOnly ? { type: "provider.usage.list.response", @@ -175,7 +218,7 @@ export async function installUsageReportsFixture( })), }, } - : { type: "usage.list_reports.response", payload: { requestId, reports } }; + : { type: "usage.list_reports.response", payload: { requestId, error: null } }; ws.send(JSON.stringify({ type: "session", message: reply })); listCounter.increment(); return; @@ -186,7 +229,7 @@ export async function installUsageReportsFixture( server.onMessage((message) => { const serverInfo = typeof message === "string" - ? withUsageSupportFeature(message, usageSupported, providerUsageListOnly) + ? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) : null; ws.send(serverInfo ?? message); }); diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 42eebe65e9..7b012c171b 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -168,6 +168,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) }); } +/** + * A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable + * but buttons, so no row presses or highlights on hover. + */ +export async function expectUnpinnableRows(card: Locator): Promise { + await expect(card.getByRole("checkbox")).toHaveCount(0); + await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0); + const focusable = await card + .locator("[tabindex]") + .evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length); + expect(focusable).toBe(0); +} + export async function togglePin(scope: Locator, source: string, window: string) { const row = pinRow(scope, source, window); const pinned = await row.isChecked(); @@ -175,6 +188,28 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } +/** The footer's Usage icon, which is there whether or not the Usage item is on. */ +export async function openUsageScreenFromIcon(page: Page): Promise { + await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 }); + await expectOnUsageScreen(page); +} + +function summaryInSidebarSwitch(page: Page): Locator { + return page.getByRole("switch", { name: "Summary in sidebar", exact: true }); +} + +/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */ +export async function setSummaryInSidebar(page: Page, on: boolean): Promise { + await openUsageOptions(page); + await summaryInSidebarSwitch(page).click(); + await expectSummaryInSidebar(page, on); +} + +export async function expectSummaryInSidebar(page: Page, on: boolean): Promise { + await openUsageOptions(page); + await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on }); +} + /** Expand the inline Settings row when its controls are folded. */ export async function openUsageOptions(page: Page): Promise { const toggle = visible(page, "usage-options-toggle"); diff --git a/packages/app/src/components/context-window-details.tsx b/packages/app/src/components/context-window-details.tsx new file mode 100644 index 0000000000..dbeffa0ff3 --- /dev/null +++ b/packages/app/src/components/context-window-details.tsx @@ -0,0 +1,74 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AgentUsage } from "@/usage"; +import { formatTokenCount } from "./context-window-meter.utils"; + +interface ContextWindowDetailsProps { + serverId: string; + agentId: string; + percentage: number; + usedTokens: number; + maxTokens: number; + sessionCost: string | null; + /** The tooltip titles itself; the sheet's header carries the title instead. */ + showTitle: boolean; + /** Only the sheet can be pressed, so only its usage cards have a Refresh button. */ + refreshable: boolean; +} + +/** + * What the context window meter opens: how full the window is, then the usage of the agent's + * account. The wide tooltip and the compact sheet both render it. + */ +export function ContextWindowDetails({ + serverId, + agentId, + percentage, + usedTokens, + maxTokens, + sessionCost, + showTitle, + refreshable, +}: ContextWindowDetailsProps) { + const { t } = useTranslation(); + return ( + <> + + {showTitle ? {t("contextWindow.title")} : null} + {t("contextWindow.used", { percentage })} + + {t("contextWindow.tokens", { + used: formatTokenCount(usedTokens), + max: formatTokenCount(maxTokens), + })} + + {sessionCost ? ( + {t("contextWindow.sessionCost", { cost: sessionCost })} + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + gap: theme.spacing[1.5], + minWidth: 200, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.4, + }, + detail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, +})); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 2696f7c1ba..c89deaeea5 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,11 +1,17 @@ -import { Pressable, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { formatTokenCount } from "./context-window-meter.utils"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHostReportsUsage } from "@/usage"; +import { ContextWindowDetails } from "./context-window-details"; +import { ContextWindowSheet } from "./context-window-sheet"; interface ContextWindowMeterProps { + serverId: string; + agentId: string; maxTokens: number | null; usedTokens: number | null; totalCostUsd?: number | null; @@ -91,6 +97,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { } export function ContextWindowMeter({ + serverId, + agentId, maxTokens, usedTokens, totalCostUsd, @@ -100,6 +108,15 @@ export function ContextWindowMeter({ }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); + const { width } = useWindowDimensions(); + // Usage cards need a wider popover; without them it keeps the plain tooltip shape. + const showsUsage = useHostReportsUsage(serverId); + const popoverWidth = Math.min(360, width - 24); + // Compact screens open the details in a sheet, which can hold a pressable Refresh. + const isCompact = useIsCompactFormFactor(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const openSheet = useCallback(() => setIsSheetOpen(true), []); + const closeSheet = useCallback(() => setIsSheetOpen(false), []); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; const geometry = getMeterGeometry(showPercentage, glyphSize); @@ -142,6 +159,71 @@ export function ContextWindowMeter({ const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; + const ring = ( + + + + + ); + const percentageLabel = showPercentage ? ( + {`${roundedPercentage}%`} + ) : null; + const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage }); + + if (isCompact) { + return ( + <> + + {ring} + {percentageLabel} + + + + + + ); + } + return ( @@ -149,62 +231,30 @@ export function ContextWindowMeter({ style={containerStyle} testID="context-window-meter" accessibilityRole="image" - accessibilityLabel={t("contextWindow.accessibility", { - percentage: roundedPercentage, - })} + accessibilityLabel={accessibilityLabel} > - - - - - {showPercentage ? ( - {`${roundedPercentage}%`} - ) : null} + {ring} + {percentageLabel} - - - {t("contextWindow.title")} - - {t("contextWindow.used", { percentage: roundedPercentage })} - - - {t("contextWindow.tokens", { - used: formatTokenCount(usedTokens), - max: formatTokenCount(maxTokens), - })} - - {formattedSessionCost ? ( - - {t("contextWindow.sessionCost", { cost: formattedSessionCost })} - - ) : null} - + + ); @@ -237,22 +287,5 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, - tooltipContent: { - gap: theme.spacing[1.5], - minWidth: 200, - }, - tooltipTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - lineHeight: theme.fontSize.base * 1.4, - }, - tooltipDetail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, + popover: { padding: theme.spacing[4], gap: theme.spacing[4] }, })); diff --git a/packages/app/src/components/context-window-sheet.tsx b/packages/app/src/components/context-window-sheet.tsx new file mode 100644 index 0000000000..5dd454e048 --- /dev/null +++ b/packages/app/src/components/context-window-sheet.tsx @@ -0,0 +1,80 @@ +import { useMemo, type ReactNode } from "react"; +import { View, useWindowDimensions } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; + +// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not +// track, so the sheet is wrapped rather than reading the theme through a hook. +// See docs/unistyles.md. +const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ + backgroundStyle: { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +/** + * The context window meter's details on compact screens, in a sheet as tall as its content. + * Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once + * the sheet closes. + */ +export function ContextWindowSheet({ + open, + onClose, + children, +}: { + open: boolean; + onClose: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]); + const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ + visible: open, + onClose, + }); + + return ( + + + + + {/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */} + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + gap: theme.spacing[4], + }, +})); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 5e1221c169..22796e7ffe 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -523,6 +523,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} style={contentStyle} @@ -549,6 +550,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} style={contentStyle} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c46e2ac15a..57b9e3fb44 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -279,6 +279,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) { } function renderContextWindowMeter( + serverId: string, + agentId: string, contextWindowMaxTokens: number | null, contextWindowUsedTokens: number | null, totalCostUsd: number | null, @@ -292,6 +294,8 @@ function renderContextWindowMeter( } return ( renderContextWindowMeter( + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, @@ -2084,6 +2090,8 @@ function ComposerContentImpl({ contextWindowMeterGlyphSize, ), [ + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index f58acba007..f7e7ba0933 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -34,7 +34,6 @@ describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban, notes], preferences: [], }); @@ -59,7 +58,6 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -81,7 +79,6 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -101,7 +98,6 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -118,18 +114,13 @@ describe("resolveSidebarNavItems", () => { }); }); -describe("resolveSidebarNavItems on compact layouts", () => { +describe("resolveSidebarNavItems for the Usage item", () => { it("hides the Usage item until it is turned on", () => { - const resolve = (compact: boolean, preferences: SidebarNavPreference[]) => - summarize( - resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }), - ); + const resolve = (preferences: SidebarNavPreference[]) => + summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences })); - expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]); - expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]); - expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([ - { key: "usage", visible: true }, - ]); + expect(resolve([])).toEqual([{ key: "usage", visible: false }]); + expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]); }); }); @@ -137,7 +128,6 @@ describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban], preferences: [], }); @@ -160,7 +150,6 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: previous, }); @@ -186,7 +175,6 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: previous, }); @@ -204,7 +192,6 @@ describe("setSidebarNavItemVisible", () => { summarize( resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [notes], preferences: next, }), @@ -215,7 +202,6 @@ describe("setSidebarNavItemVisible", () => { it("returns the normalized list unchanged for an unknown key", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [], }); @@ -229,7 +215,6 @@ describe("setSidebarNavItemVisible", () => { describe("moveSidebarNavItem", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban], preferences: [], }); @@ -307,13 +292,12 @@ describe("footer section", () => { it("resolves the Usage item first, then plugin rows", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: [], }); expect(summarize(items)).toEqual([ - { key: "usage", visible: true }, + { key: "usage", visible: false }, { key: syncKey, visible: true }, ]); }); @@ -321,18 +305,16 @@ describe("footer section", () => { it("ignores header built-ins stored in footer preferences", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [], preferences: [{ key: "history", visible: false }], }); - expect(summarize(items)).toEqual([{ key: "usage", visible: true }]); + expect(summarize(items)).toEqual([{ key: "usage", visible: false }]); }); it("ignores the footer icon buttons, which are fixed and not items", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: [ { key: "add-project", visible: false }, @@ -350,33 +332,31 @@ describe("footer section", () => { ]); }); - it("moves and hides footer rows while keeping an unavailable plugin's entry", () => { + it("moves and shows footer rows while keeping an unavailable plugin's entry", () => { const notesPreference = { key: notesKey, visible: false }; const previous: SidebarNavPreference[] = [notesPreference]; const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: previous, }); const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous }); - const hidden = setSidebarNavItemVisible({ + const shown = setSidebarNavItemVisible({ items: resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: moved, }), key: "usage", - visible: false, + visible: true, previous: moved, }); - expect(hidden).toEqual([ + expect(shown).toEqual([ notesPreference, { key: syncKey, visible: true }, - { key: "usage", visible: false }, + { key: "usage", visible: true }, ]); }); }); diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index b5da97c034..6b98821e93 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -69,14 +69,11 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin return BUILTIN_SHORTCUT_ACTIONS[id]; } -/** - * Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer - * has no room to spare for the Usage summary. - */ -const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet = new Set(["usage"]); +/** Builtins that start hidden until the user turns them on: the Usage summary is opt-in. */ +const HIDDEN_BY_DEFAULT: ReadonlySet = new Set(["usage"]); -function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean { - return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id)); +function builtinVisibleByDefault(id: BuiltinSidebarItemId): boolean { + return !HIDDEN_BY_DEFAULT.has(id); } export function pluginSidebarNavKey( @@ -95,8 +92,6 @@ function isBuiltinSidebarItemId
( export function resolveSidebarNavItems
(input: { section: Section; - /** Compact layouts start some builtins hidden; a stored preference always wins. */ - compact: boolean; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; }): SidebarNavItem
[] { @@ -131,7 +126,7 @@ export function resolveSidebarNavItems
(input: { kind: "builtin", key: id, id, - visible: builtinVisibleByDefault(id, input.compact), + visible: builtinVisibleByDefault(id), }); } for (const [key, group] of groupsByKey) { diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index 96b99a6f1b..dcebf670df 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -1,5 +1,4 @@ import { useCallback, useMemo } from "react"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useAppSettings } from "@/hooks/use-settings"; import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -28,15 +27,14 @@ export function useSidebarNavItems
( section: Section, ): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); - const compact = useIsCompactFormFactor(); const { settings, updateSettings } = useAppSettings(); const field = PREFERENCE_FIELDS[section]; const preferences = settings[field]; const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }), - [compact, pluginGroups, preferences, section], + () => resolveSidebarNavItems({ section, pluginGroups, preferences }), + [pluginGroups, preferences, section], ); const setVisible = useCallback( @@ -45,7 +43,6 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, - compact, pluginGroups, preferences: previous, }); @@ -54,7 +51,7 @@ export function useSidebarNavItems
( }; }); }, - [compact, field, pluginGroups, section, updateSettings], + [field, pluginGroups, section, updateSettings], ); const move = useCallback( @@ -63,7 +60,6 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, - compact, pluginGroups, preferences: previous, }); @@ -72,7 +68,7 @@ export function useSidebarNavItems
( }; }); }, - [compact, field, pluginGroups, section, updateSettings], + [field, pluginGroups, section, updateSettings], ); return { items, setVisible, move }; diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx new file mode 100644 index 0000000000..a31ae89478 --- /dev/null +++ b/packages/app/src/usage/agent-usage.tsx @@ -0,0 +1,66 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import { useUsageDisplay } from "./display"; +import { useAgentUsage } from "./queries"; +import type { UsageReportEntry } from "./types"; + +/** + * The usage of the account an agent runs under, for its context window details: one card per + * report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an + * agent with no account to report. + */ +export function AgentUsage({ + serverId, + agentId, + refreshable, +}: { + serverId: string; + agentId: string; + /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ + refreshable: boolean; +}) { + const view = useAgentUsage(serverId, agentId); + if (view.kind === "none") return null; + return ( + <> + + {view.kind === "ready" ? ( + + ) : ( + + {view.kind === "loading" ? usageCopy.loading : view.message} + + )} + + ); +} + +function AgentUsageCards({ + serverId, + reports, + refreshable, +}: { + serverId: string; + reports: readonly UsageReportEntry[]; + refreshable: boolean; +}) { + const { display } = useUsageDisplay(reports); + return reports.map((entry) => ( + + )); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { height: 1, backgroundColor: theme.colors.borderAccent }, + message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 8260273e06..4e894108ef 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -47,17 +47,23 @@ export function UsageCard({ serverId, entry, display, + pinnable, + refreshable, compact = false, }: { serverId: string; entry: UsageReportEntry; display: UsageDisplay; + /** Whether each window row pins the window to the sidebar. */ + pinnable: boolean; + /** Whether the header has a Refresh button. Without one the freshness shows on the card. */ + refreshable: boolean; compact?: boolean; }) { const isCompact = useIsCompactFormFactor(); const { refresh, refreshState } = useReportRefresh(serverId, entry.id); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. - const showsFreshnessInline = isNative || isCompact; + const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; @@ -93,13 +99,15 @@ export function UsageCard({ {status} ) : null} - + {refreshable ? ( + + ) : null} {message ? ( @@ -111,7 +119,13 @@ export function UsageCard({ {windows.length > 0 || balances.length > 0 ? ( {windows.map((window) => ( - + ))} {balances.map((balance) => ( @@ -158,14 +172,16 @@ export function UsageCard({ ); } -function PinnableWindowBar({ +function CardWindowBar({ entry, window, display, + pinnable, }: { entry: UsageReportEntry; window: UsageWindow; display: UsageDisplay; + pinnable: boolean; }) { const pin = useMemo( () => ({ sourceId: entry.sourceId, windowId: window.id }), @@ -177,6 +193,7 @@ function PinnableWindowBar({ `Unable to load usage: ${reason}`, hostUnavailable: (host: string) => `Connect to ${host} to see usage`, hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, clientUnavailable: "Host connection is not ready", diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index cfacf8d738..5b4e534693 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -10,6 +10,7 @@ import { type UsagePin, type UsagePreferences, } from "./preferences"; +import type { UsageReportEntry } from "./types"; /** What usage views need from the preferences: how to read percents, and the pins. */ export interface UsageDisplay { @@ -19,15 +20,21 @@ export interface UsageDisplay { togglePin: (pin: UsagePin) => void; } -/** The device's usage preferences, and the commands that change them. */ -export function useUsagePreferences(serverId?: string): { +interface UsagePreferencesState { preferences: UsagePreferences; display: UsageDisplay; -} { +} + +/** The device's usage preferences, with pins resolved against a host's reports. */ +export function useUsagePreferences(serverId?: string): UsagePreferencesState { + const defaultServerId = useUsageHostId(); + return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); +} + +/** The device's usage preferences, with pins resolved against the given reports. */ +export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { const { settings, updateSettings } = useAppSettings(); const preferences = settings.usage; - const defaultServerId = useUsageHostId(); - const reports = useUsageHostReports(serverId ?? defaultServerId); const setDisplayAs = useCallback( (displayAs: UsageDisplayAs) => { void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) })); diff --git a/packages/app/src/usage/in-sidebar.ts b/packages/app/src/usage/in-sidebar.ts new file mode 100644 index 0000000000..96883bb80f --- /dev/null +++ b/packages/app/src/usage/in-sidebar.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; + +/** + * Whether the sidebar footer shows the Usage summary: the same switch as Settings > Sidebar. + * Window rows pin to the summary only while it is on. + */ +export function useUsageInSidebar(): { + inSidebar: boolean; + setInSidebar: (visible: boolean) => void; +} { + const { items, setVisible } = useSidebarNavItems("footer"); + const inSidebar = items.some((item) => item.key === "usage" && item.visible); + const setInSidebar = useCallback( + (visible: boolean) => setVisible("usage", visible), + [setVisible], + ); + return { inSidebar, setInSidebar }; +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index 021c627e31..ab1e28b38e 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,3 +1,5 @@ +export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; +export { useHostReportsUsage } from "./queries"; export { UsageScreen } from "./usage-screen"; export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item"; diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx index f0c23dd0f7..10e76ba57f 100644 --- a/packages/app/src/usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -3,9 +3,10 @@ import { StyleSheet } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { UsageCard } from "./card"; import type { UsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import type { UsageReportEntry } from "./types"; -/** One card per report (source + account). */ +/** One card per report (source + account). Window rows pin while the sidebar summary is on. */ export function UsageList({ serverId, reports, @@ -15,11 +16,18 @@ export function UsageList({ reports: UsageReportEntry[]; display: UsageDisplay; }) { + const { inSidebar } = useUsageInSidebar(); return ( {reports.map((entry) => ( - + ))} diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 834746e1a7..1276157120 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { displayPercent, formatUsageFreshness, + resolveAgentUsageView, replaceReport, resolveUsageRefresh, resolveUsageHostId, resolveUsageScreenHostId, resolveUsageView, + settleReports, + upsertReport, usageWindowRowLabel, type UsageHost, type UsageQueryState, @@ -216,3 +219,93 @@ describe("replaceReport", () => { expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]); }); }); + +describe("upsertReport", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("starts a list from the first streamed report", () => { + expect(upsertReport(undefined, alpha)).toEqual([alpha]); + }); + + it("appends a report the list does not have yet", () => { + expect(upsertReport([alpha], beta)).toEqual([alpha, beta]); + }); + + it("replaces a report the list already has, in place", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(upsertReport([alpha, beta], streamed)).toEqual([streamed, beta]); + }); + + it("keeps a copy fetched after the streamed one, such as a Refresh that landed first", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(upsertReport([refreshed, beta], alpha)).toEqual([refreshed, beta]); + }); +}); + +describe("settleReports", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("takes the finished list, dropping reports the host no longer has", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(settleReports([alpha, beta], [streamed])).toEqual([streamed]); + }); + + it("keeps a report refreshed while the list streamed over its older copy in the list", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); + }); + + it("takes the finished list as is when nothing was shown yet", () => { + expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); + }); +}); + +describe("resolveAgentUsageView", () => { + const report = entry({ sourceId: "claude" }); + const idle: UsageQueryState = { data: undefined, error: null, isFetching: false }; + + it("shows nothing on a host that cannot report usage", () => { + expect(resolveAgentUsageView({ canReport: false, query: idle })).toEqual({ kind: "none" }); + }); + + it("loads until the first report streams in", () => { + expect( + resolveAgentUsageView({ canReport: true, query: { ...idle, isFetching: true } }), + ).toEqual({ kind: "loading" }); + }); + + it("shows each report as it streams in, before the request finishes", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: null, isFetching: true }, + }), + ).toEqual({ kind: "ready", reports: [report] }); + }); + + it("shows nothing for an agent with no account to report", () => { + expect(resolveAgentUsageView({ canReport: true, query: { ...idle, data: [] } })).toEqual({ + kind: "none", + }); + }); + + it("says why a failed request has nothing to show", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { ...idle, error: new Error("Unknown agent") }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); + + it("says the request failed instead of showing the reports from before it", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: new Error("Unknown agent"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 0e44ddae18..2b2e4c54cf 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -61,6 +61,40 @@ export function replaceReport( return reports.map((report) => (report.id === reportId ? refreshed : report)); } +/** + * The later-fetched of two copies of one report. A list request's copies were fetched when it + * started, so a Refresh that lands while it streams is newer than what the request still delivers. + */ +function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry { + return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving; +} + +/** + * A report list with one streamed report in place of its previous copy, or appended if new. A + * copy fetched after the streamed one stays. + */ +export function upsertReport( + reports: readonly UsageReportEntry[] | undefined, + report: UsageReportEntry, +): UsageReportEntry[] { + if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report]; + return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry)); +} + +/** + * The list a finished request leaves: its reports, dropping any the host no longer has, except + * that a copy on screen fetched after the request's copy stays. + */ +export function settleReports( + shown: readonly UsageReportEntry[] | undefined, + finished: readonly UsageReportEntry[], +): UsageReportEntry[] { + return finished.map((report) => { + const copy = shown?.find((entry) => entry.id === report.id); + return copy ? laterFetched(copy, report) : report; + }); +} + export interface UsageQueryState { data: UsageReportEntry[] | undefined; error: unknown; @@ -90,6 +124,31 @@ export function resolveUsageView(input: { return { kind: "loading" }; } +/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */ +export type AgentUsageView = + | { kind: "none" } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[] }; + +export function resolveAgentUsageView(input: { + canReport: boolean; + query: UsageQueryState; +}): AgentUsageView { + const { canReport, query } = input; + if (!canReport) return { kind: "none" }; + // A failed request keeps the reports from before it, or those that streamed in before it failed; + // shown alone they would pass for the agent's complete, current usage. + if (query.error) { + const reason = query.error instanceof Error ? query.error.message : String(query.error); + return { kind: "error", message: usageCopy.agentError(reason) }; + } + if (query.data) { + return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data }; + } + return { kind: "loading" }; +} + export interface UsageHost { serverId: string; label: string; diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index 18ec7c2f1a..cf2666f897 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,4 +1,3 @@ -import { useCallback } from "react"; import { View } from "react-native"; import { SettingsCard, @@ -7,10 +6,10 @@ import { SettingsSwitch, } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; -import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { settingsStyles } from "@/styles/settings"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ @@ -23,13 +22,7 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ * switch as Settings > Sidebar) and whether percents read as used or remaining. */ export function UsageOptions({ display }: { display: UsageDisplay }) { - const sidebarItems = useSidebarNavItems("footer"); - const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible); - const { setVisible } = sidebarItems; - const setInSidebar = useCallback( - (visible: boolean) => setVisible("usage", visible), - [setVisible], - ); + const { inSidebar, setInSidebar } = useUsageInSidebar(); return ( diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index f5a3fe1ed6..f054d6d082 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,6 +1,12 @@ import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; import { useCallback, useMemo } from "react"; -import { skipToken, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { + skipToken, + useMutation, + useQueryClient, + type QueryClient, + type QueryKey, +} from "@tanstack/react-query"; import { useShallow } from "zustand/shallow"; import { useFetchQuery } from "@/data/query"; import { @@ -13,11 +19,15 @@ import { useSessionStore, type SessionState } from "@/stores/session-store"; import { usageCopy } from "./copy"; import { replaceReport, + resolveAgentUsageView, resolveUsageRefresh, resolveUsageView, + settleReports, type UsageHost, type UsageQueryState, + type AgentUsageView, type UsageRefresh, + upsertReport, } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; @@ -25,8 +35,17 @@ import type { UsageReportEntry, UsageView } from "./types"; // an explicit refresh passes `forceRefresh` and reaches the source's API. const REPORTS_STALE_TIME_MS = 60_000; +/** Every report list of a host: its own and each agent's. */ +function hostUsageQueryKey(serverId: string) { + return ["usage", serverId] as const; +} + function usageReportsQueryKey(serverId: string) { - return ["usage", "reports", serverId] as const; + return [...hostUsageQueryKey(serverId), "reports"] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string) { + return [...hostUsageQueryKey(serverId), "agent", agentId] as const; } function requireClient(serverId: string) { @@ -35,8 +54,44 @@ function requireClient(serverId: string) { return client; } -async function listReports(serverId: string, forceRefresh = false): Promise { - return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports; +/** + * Lists a host's reports, or one agent's, writing each into `queryKey` as it streams in so a slow + * source never holds back the others. The finished list then replaces the streamed one, dropping + * any report the host no longer has, but never a report refreshed while the list streamed. + */ +async function streamReports(input: { + queryClient: QueryClient; + queryKey: QueryKey; + serverId: string; + agentId?: string; + forceRefresh?: boolean; + /** Once aborted, reports still on their way are dropped instead of written. */ + signal?: AbortSignal; +}): Promise { + const { queryClient, queryKey, serverId, agentId, forceRefresh = false, signal } = input; + const { reports } = await requireClient(serverId).listUsageReports( + { agentId, forceRefresh }, + (report) => { + if (signal?.aborted) return; + queryClient.setQueryData(queryKey, (current) => + upsertReport(current, report), + ); + }, + ); + return settleReports(queryClient.getQueryData(queryKey), reports); +} + +function listReports( + queryClient: QueryClient, + serverId: string, + forceRefresh = false, +): Promise { + return streamReports({ + queryClient, + queryKey: usageReportsQueryKey(serverId), + serverId, + forceRefresh, + }); } async function getReport( @@ -57,7 +112,7 @@ function supportsUsage(session: SessionState | undefined): boolean { async function refreshReports(queryClient: QueryClient, serverId: string): Promise { await queryClient.fetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId, true), + queryFn: () => listReports(queryClient, serverId, true), staleTime: 0, }); } @@ -77,7 +132,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId), + queryFn: () => listReports(queryClient, serverId), enabled: isConnected && isSupported, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, @@ -102,15 +157,51 @@ const NO_REPORTS: UsageReportEntry[] = []; * load, or without a host. */ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const queryClient = useQueryClient(); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId ?? ""), - queryFn: serverId ? () => listReports(serverId) : skipToken, + queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); return query.data ?? NO_REPORTS; } +/** + * The reports of the account an agent runs under, as its meter's tooltip or sheet shows them. + * Fetched while either is mounted, so only while it is open. + */ +export function useAgentUsage(serverId: string, agentId: string): AgentUsageView { + const queryClient = useQueryClient(); + const canReport = useHostReportsUsage(serverId); + const queryKey = agentUsageQueryKey(serverId, agentId); + const query = useFetchQuery({ + queryKey, + // Consuming the signal cancels the request when the details close, so a reopen sends a new + // request instead of joining one scoped to the login the agent ran under before. + queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }), + enabled: canReport, + // Another agent's reports never stand in while this one's load. + dataShape: "value", + // Dropped as soon as the details close: an agent resumed under another login keeps its ID, so + // reports kept from an earlier open could show the old login. The daemon's cache keeps each + // open's request cheap. + gcTime: 0, + // The daemon's errors (an unknown agent) do not heal on retry, and reopening the details + // fetches again; retrying would hold the loading sentence for seconds instead. + retry: false, + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + return resolveAgentUsageView({ canReport, query: toQueryState(query) }); +} + +/** Whether a host is connected and reports usage, read without fetching anything. */ +export function useHostReportsUsage(serverId: string): boolean { + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + return isConnected && isSupported; +} + /** Every host with whether it is connected and reports usage, in host order. */ export function useUsageHosts(): UsageHost[] { const hosts = useHosts(); @@ -133,8 +224,8 @@ export function useUsageHosts(): UsageHost[] { /** * Forces the source to fetch one report, and only that report. The result replaces the report - * in its host's list, so every surface showing it moves together; until then the previous report - * stays on screen. + * in every list of its host that holds it, the host's and each agent's, so every surface showing + * it moves together; until then the previous report stays on screen. */ export function useReportRefresh( serverId: string, @@ -144,8 +235,9 @@ export function useReportRefresh( const mutation = useMutation({ mutationFn: () => getReport(serverId, reportId, true), onSuccess: (report) => { - queryClient.setQueryData(usageReportsQueryKey(serverId), (reports) => - reports ? replaceReport(reports, reportId, report) : reports, + queryClient.setQueriesData( + { queryKey: hostUsageQueryKey(serverId) }, + (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, }); diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 8b668eab00..5260a1c44b 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -21,6 +21,7 @@ function highlightStyle(pinned: boolean, hovered: boolean) { export function UsageWindowBar({ window, displayAs, + pinnable, pinned, onTogglePin, pinLabel, @@ -28,6 +29,8 @@ export function UsageWindowBar({ }: { window: UsageWindow; displayAs: UsageDisplayAs; + /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ + pinnable: boolean; pinned: boolean; onTogglePin: () => void; /** What the row pins, naming the source and window: "Pin Claude Session". */ @@ -44,6 +47,24 @@ export function UsageWindowBar({ const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + const content = { + label: window.label, + value, + trailing, + isAtRisk, + percent: shownPct ?? 0, + tone, + pinned, + }; + + // Same padding as the pinnable row, so bars line up in both modes. + if (!pinnable) { + return ( + + + + ); + } // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same // padding so toggling only changes the background. @@ -59,14 +80,9 @@ export function UsageWindowBar({ > {({ hovered }: { hovered?: boolean }) => ( )} @@ -81,6 +97,7 @@ function WindowRowContent({ isAtRisk, percent, tone, + pinnable, pinned, }: { highlight: StyleProp; @@ -90,6 +107,7 @@ function WindowRowContent({ isAtRisk: boolean; percent: number; tone: UsageTone; + pinnable: boolean; pinned: boolean; }) { return ( @@ -110,7 +128,7 @@ function WindowRowContent({ - + {pinnable ? : null} ); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 9870db034d..d7ab645241 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -7206,11 +7206,15 @@ test("uses modern usage RPC when both capabilities are advertised", async () => const connected = client.connect(); mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } }); await connected; - const result = client.listUsageReports({ - requestId: "modern-usage", - reportIds: ["claude:work"], - forceRefresh: true, - }); + const updates: unknown[] = []; + const result = client.listUsageReports( + { + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }, + (report) => updates.push(report), + ); expect(parseSentFrame(mock.sent[0])).toEqual({ type: "usage.list_reports.request", requestId: "modern-usage", @@ -7230,8 +7234,35 @@ test("uses modern usage RPC when both capabilities are advertised", async () => }, ], }; - mock.triggerMessage(wrapSessionMessage({ type: "usage.list_reports.response", payload })); + const report = payload.reports[0]; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: "foreign", report }, + }), + ); + expect(updates).toEqual([]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); + expect(updates).toEqual([report]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.response", + payload: { requestId: payload.requestId, error: null }, + }), + ); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); expect(await result).toStrictEqual(payload); + expect(updates).toEqual([report]); }); test("rejects usage requests when the host has neither capability", async () => { @@ -7250,3 +7281,47 @@ test("rejects usage requests when the host has neither capability", async () => await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage."); expect(mock.sent).toEqual([]); }); + +test("usage request timeout detaches its update listener", async () => { + vi.useFakeTimers(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { usageSources: true } }); + await connected; + const updates: unknown[] = []; + const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) => + updates.push(report), + ); + const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)"); + await vi.advanceTimersByTimeAsync(60_001); + await rejected; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { + requestId: "timeout-usage", + report: { + id: "source:late", + account: {}, + sourceId: "source", + sourceLabel: "Source", + fetchedAt: "2026-01-01T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + }, + }), + ); + expect(updates).toEqual([]); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ab5427fe31..858c61e5b3 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -95,7 +95,7 @@ import type { RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, - UsageListReportsResponseMessage, + UsageReportEntry, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -562,7 +562,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload" type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; -type UsageListReportsPayload = UsageListReportsResponseMessage["payload"]; +interface UsageListReportsPayload { + requestId: string; + reports: UsageReportEntry[]; +} type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -5259,17 +5262,25 @@ export class DaemonClient { }); } - async listUsageReports(options?: { - requestId?: string; - forceRefresh?: boolean; - reportIds?: string[]; - }): Promise { + async listUsageReports( + options?: { + agentId?: string; + requestId?: string; + forceRefresh?: boolean; + reportIds?: string[]; + }, + onReport?: (report: UsageReportEntry) => void, + ): Promise { const features = this.getLastServerInfoMessage()?.features; if (!supportsUsageReports(features)) { throw new Error("Update the host to see usage."); } + if (options?.agentId !== undefined && options.reportIds !== undefined) + throw new Error("agentId and reportIds cannot be combined"); // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. if (features?.usageSources !== true) { + if (options?.agentId !== undefined) + return { requestId: this.createRequestId(options.requestId), reports: [] }; // Released hosts serve a five-minute cache and have no forceRefresh option. const payload = await this.listProviderUsage({ requestId: options?.requestId }); return { @@ -5310,14 +5321,46 @@ export class DaemonClient { }), }; } - return this.sendNamespacedCorrelatedSessionRequest({ - requestId: options?.requestId, - message: { - type: "usage.list_reports.request", - forceRefresh: options?.forceRefresh, - reportIds: options?.reportIds, - }, + const requestId = this.createRequestId(options?.requestId); + const reports: UsageReportEntry[] = []; + let active = true; + const unsubscribe = this.subscribeRawMessages((message) => { + if ( + !active || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== requestId + ) + return; + if (message.type === "usage.list_reports.response" || message.type === "rpc_error") { + active = false; + return; + } + if (message.type !== "usage.list_reports.update") return; + reports.push(message.payload.report); + onReport?.(message.payload.report); }); + try { + const response = await this.sendRequest({ + requestId, + message: { + type: "usage.list_reports.request", + requestId, + forceRefresh: options?.forceRefresh, + reportIds: options?.reportIds, + agentId: options?.agentId, + }, + select: (message) => + message.type === "usage.list_reports.response" && message.payload.requestId === requestId + ? message.payload + : null, + }); + if (response.error !== null) throw new Error(response.error); + return { requestId, reports }; + } finally { + active = false; + unsubscribe(); + } } async listCommands(options: ListCommandsOptions): Promise; diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 1b78f8fe12..4ad2661d67 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { ZodType } from "zod"; +import { z, type ZodType } from "zod"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { @@ -62,13 +62,24 @@ export interface UsageAccount { input: JsonValue; } +export const UsageScopeSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("global") }), + z.object({ + kind: z.literal("session"), + provider: z.string(), + model: z.string().optional(), + env: z.record(z.string(), z.string()), + }), +]); +export type UsageScope = z.infer; + export interface UsageSourceRegistration { id: string; label: string; icon?: string; input: ZodType; - /** Every account whose login exists on this machine. Empty when none. */ - discover(): Promise; + /** Accounts for this scope only. The same key in any scope identifies the same account. */ + discover(scope: UsageScope): Promise; /** Re-reads the login store; never writes it. */ fetch(input: unknown): Promise; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 47610620d7..0214502263 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1788,6 +1788,7 @@ export const ProviderUsageListRequestMessageSchema = z.object({ export const UsageListReportsRequestMessageSchema = z.object({ type: z.literal("usage.list_reports.request"), + agentId: z.string().optional(), requestId: z.string(), reportIds: z.array(z.string()).optional(), forceRefresh: z.boolean().optional(), @@ -6289,9 +6290,13 @@ export const UsageReportEntrySchema = z.object({ icon: z.string().optional(), report: UsageReportSchema, }); +export const UsageListReportsUpdateMessageSchema = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ requestId: z.string(), report: UsageReportEntrySchema }), +}); export const UsageListReportsResponseMessageSchema = z.object({ type: z.literal("usage.list_reports.response"), - payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }), + payload: z.object({ requestId: z.string(), error: z.string().nullable() }), }); const AgentSlashCommandSchema = z.object({ @@ -6979,6 +6984,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsUpdateMessageSchema, UsageListReportsResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2f9cd425ae..1aafb98996 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -11417,3 +11417,33 @@ test("failed startup history closes the session without registering an agent", a for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); } }); + +test("usage session is a pure read of the live adapter and disappears on close", async () => { + const descriptor = { + provider: "claude", + model: "opus", + env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" }, + sessionKey: "launch-1", + }; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new (class extends TestAgentSession { + usageSession() { + return descriptor; + } + })(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + expect(manager.usageSession("missing")).toBeNull(); + const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, { + workspaceId: undefined, + }); + try { + expect(manager.usageSession(agent.id)).toBe(descriptor); + expect(manager.usageSession(agent.id)).toBe(descriptor); + } finally { + await manager.closeAgent(agent.id); + } + expect(manager.usageSession(agent.id)).toBeNull(); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 7c39876b30..920f960e5c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1157,6 +1157,11 @@ export class AgentManager { } } + usageSession(id: string) { + const agent = this.agents.get(id); + return agent?.session?.usageSession?.() ?? null; + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a0ed0eedc3..3e5e739577 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -647,7 +647,15 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } +export interface AgentUsageSession { + provider: string; + model?: string; + env: Record; + sessionKey: string; +} + export interface AgentSession { + usageSession?(): AgentUsageSession | null; readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 3a66ffcfcf..bd22d37df3 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -449,6 +449,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession get features() { return inner.features; }, + usageSession: inner.usageSession?.bind(inner), run: (prompt, options) => inner.run(prompt, options), startTurn: (prompt, options) => inner.startTurn(prompt, options), steerActiveTurn: inner.steerActiveTurn?.bind(inner), diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 642e562f61..c99687e438 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -90,8 +90,10 @@ describe("Claude SDK env", () => { expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout"); expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout"); + expect(session.usageSession?.()?.env).toBe(capturedEnv); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } }); @@ -150,12 +152,19 @@ describe("Claude SDK env", () => { ); try { + const descriptor = session.usageSession?.(); + expect(descriptor?.env.PASEO_TEST_FLAG).toBe("resume-launch-value"); + expect(descriptor?.sessionKey).toEqual(expect.any(String)); + expect(queryFactory).not.toHaveBeenCalled(); const result = await session.run("resume env check"); + expect(session.usageSession?.()?.sessionKey).toBe(descriptor?.sessionKey); + expect(capturedEnv).toBe(descriptor?.env); expect(result.sessionId).toBe("persisted-session"); expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index e5153e2102..15eeb1500a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2053,6 +2053,8 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; + private readonly harnessEnvironment: Record; + private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; private input: AsyncMessageInput | null = null; /** The exact SDK query/input pair that owns the current foreground turn. */ @@ -2128,6 +2130,7 @@ class ClaudeAgentSession implements AgentSession { this.agentId = options.agentId; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; + this.harnessEnvironment = this.buildSdkEnv(); this.persistSession = options.persistSession; this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; @@ -2422,7 +2425,7 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; - assertClaudeModeCanRun(normalized, this.buildSdkEnv()); + assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; const activeQuery = await this.ensureQuery(); await activeQuery.setPermissionMode(normalized); @@ -3255,7 +3258,17 @@ class ClaudeAgentSession implements AgentSession { ); } - private buildSdkEnv(): NodeJS.ProcessEnv { + usageSession() { + if (this.closed) return null; + return { + provider: "claude", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + + private buildSdkEnv() { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, @@ -3271,7 +3284,7 @@ class ClaudeAgentSession implements AgentSession { this.config.toolPolicy, ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); - const sdkEnv = this.buildSdkEnv(); + const sdkEnv = this.harnessEnvironment; assertClaudeModeCanRun(this.currentMode, sdkEnv); const claudeBinary = await this.resolveBinary(); @@ -5064,7 +5077,7 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = claudeConfigDir(this.buildSdkEnv()); + const configDir = claudeConfigDir(this.harnessEnvironment); const candidates = [cwd]; try { const realCwd = fs.realpathSync(cwd); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 032eeb65cb..c8269722a7 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -698,6 +698,11 @@ process.stdin.on("data", (chunk) => { }); try { + expect(session.usageSession?.()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: providerCodexHome }, + }); + expect(session.usageSession?.()?.sessionKey).toBe(session.usageSession?.()?.sessionKey); return await run({ session, readCaptured: () => @@ -708,6 +713,7 @@ process.stdin.on("data", (chunk) => { }); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); vi.unstubAllEnvs(); rmSync(tempDir, { recursive: true, force: true }); } @@ -1792,6 +1798,28 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("resumed session exposes usage before connecting and not after close", async () => { + let spawns = 0; + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + { sessionId: "saved-thread" }, + createTestLogger(), + async () => { + spawns++; + throw new Error("unexpected spawn"); + }, + { environment: { HOME: "/fixture/codex", CODEX_HOME: "/fixture/profile" } }, + ); + expect(session.usageSession()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: "/fixture/profile" }, + sessionKey: expect.any(String), + }); + expect(spawns).toBe(0); + await session.close(); + expect(session.usageSession()).toBeNull(); + }); + test("loads archived Codex history without resuming the native thread", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 019a03bc59..36267592f8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -63,7 +63,6 @@ import { import { checkProviderLaunchAvailable, createProviderEnv, - createProviderEnvSpec, resolveProviderLaunch, type ProviderRuntimeSettings, type ResolvedProviderLaunch, @@ -272,6 +271,7 @@ interface CodexAppServerClientLike { } interface CodexAppServerAgentDeps { + environment?: Record; workspaceGitService?: Pick; customProvider?: { id: string; @@ -3323,7 +3323,7 @@ function toCodexTextInput(text: string): Extract, -): NodeJS.ProcessEnv { +) { return createProviderEnv({ runtimeSettings, overlays: [launchEnv], @@ -3521,6 +3521,19 @@ export class CodexAppServerAgentSession implements AgentSession { } | null = null; private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null; + private readonly usageSessionKey = randomUUID(); + private readonly harnessEnvironment: Record; + + usageSession() { + if (this.closed) return null; + return { + provider: "codex", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + constructor( config: AgentSessionConfig, private readonly resumeHandle: { sessionId: string; metadata?: Record } | null, @@ -3546,6 +3559,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.providerOptions = validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {}; this.config = config; + this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv(); this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); @@ -7211,7 +7225,7 @@ export class CodexAppServerAgentClient implements AgentClient { private async spawnAppServer( launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string }, + options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record }, ): Promise { const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const args = [...launchPrefix.args, "app-server"]; @@ -7230,10 +7244,7 @@ export class CodexAppServerAgentClient implements AgentClient { const child = spawnProcess(launchPrefix.command, args, { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], - ...createProviderEnvSpec({ - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv], - }), + baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), }); assertChildWithPipes(child); return child; @@ -7254,13 +7265,18 @@ export class CodexAppServerAgentClient implements AgentClient { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(launchContext?.env), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, options?.persistSession === false, goalsEnabled, autoReviewEnabled, @@ -7286,13 +7302,18 @@ export class CodexAppServerAgentClient implements AgentClient { }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(launchContext?.env), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, false, goalsEnabled, autoReviewEnabled, diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 3841f8bd85..75034dc99e 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -958,6 +958,9 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); + await omp.close(); + expect(omp.usageSession()).toBeNull(); }); test("maps permissions and sends the selected OMP response", async () => { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 089376c8ca..ce29ea6d29 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -723,6 +723,19 @@ export class OmpAgentSession implements AgentSession { private readonly emittedUserMessageIds = new Set(); private customMessageIndex = 0; + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "omp", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } + constructor(options: OmpAgentSessionOptions) { this.runtimeSession = options.runtimeSession; this.hostTools = options.hostTools; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index f312079491..ebe99ead03 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -127,7 +127,13 @@ describe("OMP CLI runtime", () => { onOmpCommand(child, (command) => { if (command.type === "steer") pending = command; }); - const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const launches: OmpRuntimeLaunch[] = []; + const session = await createRuntime(child, launches).startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/omp-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/omp-home"); const result = session.steer("change direction"); await new Promise((resolve) => setImmediate(resolve)); expect(pending).toMatchObject({ type: "steer", message: "change direction" }); @@ -485,7 +491,10 @@ describe("OMP CLI runtime", () => { const child = createOmpChild(); const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const environment = session.environment; child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); await expect(session.abort()).resolves.toBeUndefined(); }); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 61ca592993..51d340ab2c 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -1,3 +1,4 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import { z } from "zod"; @@ -74,6 +75,7 @@ export class OmpCliRuntime implements OmpRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -98,7 +100,7 @@ export class OmpCliRuntime implements OmpRuntime { requestTimeoutMs: this.options.requestTimeoutMs, }); input.signal?.throwIfAborted(); - return new OmpCliRuntimeSession(process, this.commandsRpcName); + return new OmpCliRuntimeSession(process, this.commandsRpcName, launch.env); } catch (error) { const startupError = error instanceof Error ? error : new Error(String(error)); await process.close(startupError); @@ -116,6 +118,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: "get_available_commands", + private readonly launchEnvironment: Record, ) { process.onMessage((message) => { const event = OmpRuntimeEventSchema.safeParse(message); @@ -128,6 +131,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { }); } + get environment(): Record { + return this.launchEnvironment; + } + onEvent(callback: (event: OmpRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index 2381610ff6..691e907139 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -43,6 +43,7 @@ export interface OmpStartSessionInput { } export interface OmpRuntimeSession { + readonly environment: Record; onEvent(callback: (event: OmpRuntimeEvent) => void): () => void; prompt( message: string, diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 184a38bafa..c54defed6f 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -115,6 +115,7 @@ export class FakeOmp implements OmpRuntime { } export class FakeOmpSession implements OmpRuntimeSession { + readonly environment: Record; fastModeResult = { enabled: false, active: false }; readonly setFastModeRequests: boolean[] = []; readonly prompts: Array<{ message: string; imageCount: number }> = []; @@ -174,6 +175,7 @@ export class FakeOmpSession implements OmpRuntimeSession { null; constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index b292a9b32f..f7f0039257 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -566,6 +566,10 @@ export class OmpHarness { return this.events.filter((event) => event.type === "turn_canceled").length; } + usageSession() { + return this.requireSession().usageSession(); + } + async close(): Promise { await this.requireSession().close(); await waitForImmediate(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 15bae2f56c..d6b6c5513a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1618,6 +1618,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1728,6 +1729,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1812,6 +1814,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1853,6 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), undefined, undefined, @@ -1881,6 +1885,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); await session.close(); @@ -1905,6 +1910,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), events, ); @@ -1977,6 +1983,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2067,6 +2074,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2119,6 +2127,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2232,6 +2241,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2347,6 +2357,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const events: AgentStreamEvent[] = []; @@ -3146,6 +3157,7 @@ describe("OpenCode adapter startTurn error handling", () => { sdkClient, "ses_sync_abort_failure", createTestLogger(), + {}, ); try { @@ -3744,7 +3756,10 @@ describe("OpenCode adapter startTurn error handling", () => { try { await session.startTurn("first"); await session.interrupt(); + const descriptor = session.usageSession?.(); openCode.emitEvent({ type: "server-exited", error: new Error("OpenCode exited") }); + expect(session.usageSession?.()).toEqual(descriptor); + expect(descriptor).not.toBeNull(); await vi.advanceTimersByTimeAsync(0); openCode.sessionPromptAsyncEvents = [ @@ -3799,6 +3814,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_timeout", createTestLogger(), + {}, new Map(), { ready: () => new Promise(() => undefined), @@ -3852,6 +3868,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_slow_stream", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -3884,6 +3901,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_retry", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -5562,6 +5580,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -5720,6 +5739,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -6976,9 +6996,14 @@ describe("OpenCode session permission rules", () => { metadata: { cwd }, }); try { + expect(session.usageSession?.()).toMatchObject({ + provider: "opencode", + sessionKey: expect.any(String), + }); expect(openCode.calls.sessionUpdate).toEqual([]); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } } finally { rmSync(cwd, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ccd605ef06..5b75733f33 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -13,7 +13,7 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import pLimit from "p-limit"; @@ -1492,6 +1492,7 @@ export class OpenCodeAgentClient implements AgentClient { client, session.id, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1546,6 +1547,7 @@ export class OpenCodeAgentClient implements AgentClient { client, handle.sessionId, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1594,6 +1596,7 @@ export class OpenCodeAgentClient implements AgentClient { return { client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }), events: acquisition.events, + environment: acquisition.environment, url: acquisition.server.url, release: acquisition.release, }; @@ -3357,6 +3360,7 @@ async function listOpenCodeChildSessions( /** One OpenCode server generation a session talks to. */ interface OpenCodeServerConnection { + environment: Record; client: OpencodeClient; events: OpenCodeEventSource; url: string; @@ -3437,11 +3441,13 @@ class OpenCodeAgentSession implements AgentSession { private closed = false; private readonly persistSession: boolean; private deletedFromProvider = false; + private readonly usageSessionKey = randomUUID(); constructor( config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string, logger: Logger, + private readonly harnessEnvironment: Record, modelContextWindowsByModelKey: ReadonlyMap = new Map(), events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE, releaseServer: () => Promise = async () => undefined, @@ -3453,7 +3459,13 @@ class OpenCodeAgentSession implements AgentSession { connectServer?: () => Promise, ) { this.config = config; - this.server = { client, events, url: serverUrl ?? "", release: releaseServer }; + this.server = { + client, + events, + url: serverUrl ?? "", + release: releaseServer, + environment: this.harnessEnvironment, + }; this.connectServer = connectServer ?? null; this.sessionId = sessionId; this.logger = logger.child({ agentId: this.agentId }); @@ -3468,6 +3480,16 @@ class OpenCodeAgentSession implements AgentSession { this.subscribeServerEvents(); } + usageSession() { + if (this.closed) return null; + return { + provider: "opencode", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + private get client(): OpencodeClient { return this.server.client; } diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 2167913d0e..151fb57610 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -88,6 +88,7 @@ describe("OpenCodeServerManager generations", () => { const acquisition = await manager.acquireCurrent(); expect(runtime.spawnCalls[0]?.options.baseEnv).toEqual(baseEnv); + expect(acquisition.environment).toBe(runtime.spawnCalls[0]?.options.baseEnv); await acquisition.release(); }); diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index f1cd43030e..cd560af7ba 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -11,7 +11,7 @@ import { spawnProcess, type SpawnProcessOptions } from "../../../../utils/spawn. import { terminateWithTreeKill, type ProcessTerminator } from "../../../../utils/tree-kill.js"; import type { ManagedProcessRegistry } from "../../../managed-processes/managed-processes.js"; import { - createProviderEnvSpec, + createProviderEnv, resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; @@ -30,6 +30,7 @@ const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; export interface OpenCodeServerAcquisition { + environment: Record; server: { port: number; url: string }; events: OpenCodeEventSource; release: () => Promise; @@ -44,6 +45,7 @@ export interface OpenCodeServerManagerLike { } export interface OpenCodeServerGeneration { + environment: Record; process: ChildProcess; port: number; url: string; @@ -211,6 +213,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return { server: { port: server.port, url: server.url }, events: server.events, + environment: server.environment, release: async () => { if (releasePromise) { return releasePromise; @@ -332,15 +335,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const bridgeEnv = this.decorateServerEnv?.( existingConfigContent ? { OPENCODE_CONFIG_CONTENT: existingConfigContent } : {}, ); + const environment = createProviderEnv({ + baseEnv: this.baseEnv, + runtimeSettings: this.runtimeSettings, + overlays: [launchEnv, bridgeEnv], + }); const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], - ...createProviderEnvSpec({ - baseEnv: this.baseEnv, - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv, bridgeEnv], - }), + baseEnv: environment, }); const managedProcessRecord = this.recordManagedServerProcess({ process: serverProcess, @@ -353,6 +357,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { resolveProcessExit = resolve; }); const server: OpenCodeServerGeneration = { + environment, process: serverProcess, port, url, diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index fc1748dd28..aa8e644a10 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -46,6 +46,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { this.acquisitions.push(acquisition); return { server: this.server, + environment: input.env ?? {}, events, release: async () => { acquisition.released = true; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 59c3f9d695..7bad72b8b1 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -74,6 +74,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { return { server: this.server, events: this.events, + environment: input.env ?? {}, release: async () => { acquisition.releaseCount += 1; }, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index bf04d54071..b87e0bace0 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -1495,7 +1495,7 @@ describe("PiRpcAgentSession", () => { const pi = new FakePi(); const client = createClient(pi); - await client.resumeSession( + const session = await client.resumeSession( { provider: "pi", sessionId: "pi-session-1", @@ -1510,6 +1510,12 @@ describe("PiRpcAgentSession", () => { { env: { RESUME_PROBE: "expected" } }, ); + expect(session.usageSession?.()).toMatchObject({ + provider: "pi", + sessionKey: expect.any(String), + }); + await session.close(); + expect(session.usageSession?.()).toBeNull(); expect(pi.recordedLaunches).toHaveLength(1); const actualLaunch = pi.recordedLaunches[0]!; expect(actualLaunch).toMatchObject({ diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 6b5de0314e..5a281ae0a6 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1202,6 +1202,19 @@ export class PiRpcAgentSession implements AgentSession { // turn ID; retain their errors too until the cancellation request settles. private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "pi", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } + constructor(options: PiRpcAgentSessionOptions) { this.runtimeSession = options.runtimeSession; this.config = options.config; diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index c178bb5405..463870760f 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -146,7 +146,12 @@ describe("PiCliRuntime", () => { const launches: PiRuntimeLaunch[] = []; const runtime = createRuntime(child, launches); - const session = await runtime.startSession({ cwd: "/workspace/project" }); + const session = await runtime.startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/pi-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/pi-home"); await expect(session.getState()).resolves.toMatchObject({ sessionId: "pi-session-1", @@ -287,7 +292,10 @@ describe("PiCliRuntime", () => { const state = session.getState(); child.stderr.write("boom"); + const environment = session.environment; child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); await expect(state).rejects.toThrow("boom"); }); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 0262ac13a2..62ee01c503 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -1,3 +1,4 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; @@ -56,6 +57,7 @@ export class PiCliRuntime implements PiRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -76,7 +78,7 @@ export class PiCliRuntime implements PiRuntime { await process.close(input.signal.reason); input.signal.throwIfAborted(); } - return new PiCliRuntimeSession(process, this.commandsRpcName); + return new PiCliRuntimeSession(process, this.commandsRpcName, launch.env); } } @@ -86,6 +88,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: string, + private readonly launchEnvironment: Record, ) { process.onMessage((message) => { this.emit(message as PiRuntimeEvent); @@ -95,6 +98,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { }); } + get environment(): Record { + return this.launchEnvironment; + } + onEvent(callback: (event: PiRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 3582a902bd..1064952e6d 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -40,6 +40,7 @@ export interface PiStartSessionInput { } export interface PiRuntimeSession { + readonly environment: Record; onEvent(callback: (event: PiRuntimeEvent) => void): () => void; prompt( message: string, diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 50a0bf9532..5b52b1eda4 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -93,6 +93,7 @@ export class FakePi implements PiRuntime { } export class FakePiSession implements PiRuntimeSession { + readonly environment: Record; readonly prompts: Array<{ message: string; imageCount: number }> = []; readonly steerCalls: Array<{ message: string; imageCount: number }> = []; steerError: Error | null = null; @@ -145,6 +146,7 @@ export class FakePiSession implements PiRuntimeSession { null; constructor(launch: PiRuntimeLaunch) { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 214544c39c..e7e77bc0d3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -374,6 +374,7 @@ const OUTBOUND_PERMISSION = { "project.update": "workspace.read", project_icon_response: "workspace.read", "provider.usage.list.response": "daemon.read", + "usage.list_reports.update": "daemon.read", "usage.list_reports.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0166d460f4..8ace188b51 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -618,7 +618,11 @@ export async function createPaseoDaemon( }); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); - const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { + const pluginRuntime: PluginService = new PluginService(logger, daemonConfigStore, daemonVersion, { + usageAgents: { + hasAgent: (id) => agentManager.getAgent(id) !== null, + usageSession: (id) => agentManager.usageSession(id), + }, managedSources: new ManagedPluginSources(config.paseoHome, { registries: config.pluginRegistries, defaultUrl: config.pluginRegistryUrl, diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 3940ef033f..7f74c847df 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -26,7 +26,11 @@ import { PluginRuntime } from "./runtime.js"; import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { readPluginProviderIcon } from "./provider-icon.js"; -import { UsageSourceRegistry } from "./usage-sources/index.js"; +import { + type AgentUsageLookup, + type ListUsageReportsOptions, + UsageSourceRegistry, +} from "./usage-sources/index.js"; import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); @@ -56,6 +60,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + usageAgents?: AgentUsageLookup; settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; @@ -96,7 +101,12 @@ export class PluginService { private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); - this.usageSources = new UsageSourceRegistry(Date.now, 300_000, this.logger); + this.usageSources = new UsageSourceRegistry( + Date.now, + 300_000, + this.logger, + dependencies.usageAgents, + ); this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion, { @@ -149,7 +159,7 @@ export class PluginService { return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); } - listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[] }) { + listUsageReports(options?: ListUsageReportsOptions) { return this.usageSources.listReports(options); } @@ -573,8 +583,8 @@ export class PluginService { id: source.id, label: source.label, icon: source.icon, - discover: async () => { - const result = await this.runtime.discoverUsage(pluginId, source.id); + discover: async (scope) => { + const result = await this.runtime.discoverUsage(pluginId, source.id, scope); if (!Array.isArray(result)) throw new Error(`Invalid usage discovery from ${source.id}`); return result; diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 9b543b2a75..e959780389 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,3 +1,4 @@ +import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, ProviderCatalogOptions, @@ -52,7 +53,7 @@ export type PluginProcessRequest = | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } - | { type: "usage.discover"; requestId: string; sourceId: string } + | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -199,7 +200,12 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis }) .strict(), z - .object({ type: z.literal("usage.discover"), requestId: z.string(), sourceId: z.string() }) + .object({ + type: z.literal("usage.discover"), + requestId: z.string(), + sourceId: z.string(), + scope: UsageScopeSchema, + }) .strict(), z .object({ diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 93c9a3d922..3f11926d7d 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -344,7 +344,8 @@ export function createPluginWorker(options: { void (async () => { const source = usageSources.get(message.sourceId); if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); - if (message.type === "usage.discover") return jsonTransportValue(await source.discover()); + if (message.type === "usage.discover") + return jsonTransportValue(await source.discover(message.scope)); const input = await source.input.parseAsync(message.input); return jsonTransportValue(await source.fetch(input)); })().then( diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 66d71d277d..9886a368e7 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,4 @@ +import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { ProviderStatusSchema, type ProviderStatus, @@ -402,10 +403,15 @@ export class PluginRuntime { return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input }); } - discoverUsage(pluginId: string, sourceId: string): Promise { + discoverUsage(pluginId: string, sourceId: string, scope: UsageScope): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); - return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId }); + return this.request(loaded, { + type: "usage.discover", + requestId: randomUUID(), + sourceId, + scope, + }); } async getProviderStatus( diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index cff548adc5..037da5e0c6 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -21,7 +21,12 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); try { await client.connect(); - const first = await client.listUsageReports(); + const updates: string[] = []; + const first = await client.listUsageReports({}, (report) => updates.push(report.id)); + expect(updates).toEqual(first.reports.map((report) => report.id)); + await expect(client.listUsageReports({ agentId: "missing-agent" })).rejects.toThrow( + "Unknown agent", + ); expect(first.reports).toHaveLength(4); expect(first.reports[0]?.icon).toContain(" discover(lookup${source === "claude" ? ", fetchApi" : ""}), + discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}), fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup), }); return () => {}; diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 17ad174a29..338ebd1d93 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -126,7 +126,7 @@ test("rediscovery removes an old account from targeted refreshes", async () => { expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]); }); -test("discovery failures log once and produce no card", async () => { +test("discovery failures log once while other sources still report", async () => { const warnings: unknown[][] = []; const registry = new UsageSourceRegistry(Date.now, 300_000, { warn: (...args: unknown[]) => { @@ -142,7 +142,11 @@ test("discovery failures log once and produce no card", async () => { }, }), ); - expect(await registry.listReports()).toEqual([]); + registry.register(source({ id: "healthy", discover: async () => [{ key: "one", input: {} }] })); + const updates: string[] = []; + const reports = await registry.listReports({ onReport: (report) => updates.push(report.id) }); + expect(reports.map((report) => report.id)).toEqual(["healthy:one"]); + expect(updates).toEqual(["healthy:one"]); expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]); }); @@ -279,3 +283,93 @@ test("failed fallbacks preserve the final problem", async () => { problem: { kind: "rejected", status: 403 }, }); }); + +test("session discovery reuses accounts, separates launches, and retains live agent reports globally", async () => { + const sessions = new Map([ + ["one", { provider: "claude", env: { HOME: "/one" }, sessionKey: "launch-1" }], + ["two", { provider: "claude", env: { HOME: "/two" }, sessionKey: "launch-2" }], + ]); + const existing = new Set(["one", "two", "closed"]); + const discoveries: string[] = []; + let fetches = 0; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => existing.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "claude", + label: "Claude", + discover: async (scope) => { + discoveries.push(scope.kind === "global" ? "global" : scope.env.HOME); + if (scope.kind === "global") return [{ key: "default", input: "default" }]; + return [{ key: scope.env.HOME === "/new" ? "new" : "same", input: scope.env.HOME }]; + }, + fetch: async () => { + fetches++; + return { status: "available", windows: [] }; + }, + }); + expect(await registry.listReports({ agentId: "closed" })).toEqual([]); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual([ + "claude:same", + ]); + await registry.listReports({ agentId: "one" }); + await registry.listReports({ agentId: "two" }); + expect(discoveries).toEqual(["/one", "/two"]); + expect(fetches).toBe(1); + expect((await registry.listReports()).map((r) => r.id)).toEqual([ + "claude:default", + "claude:same", + ]); + sessions.set("one", { provider: "claude", env: { HOME: "/new" }, sessionKey: "launch-3" }); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual(["claude:new"]); + existing.delete("two"); + sessions.delete("two"); + expect((await registry.listReports()).map((r) => r.id)).toEqual(["claude:default", "claude:new"]); + await expect(registry.listReports({ agentId: "missing" })).rejects.toThrow("Unknown agent"); + await expect(registry.listReports({ agentId: "one", reportIds: [] })).rejects.toThrow("agentId"); +}); + +test("reports arrive independently and a hung fetch settles as an error before completion", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + let finishSlow!: (value: unknown) => void; + const slow = new Promise((resolve) => { + finishSlow = resolve; + }); + const reports: string[] = []; + let firstReport!: () => void; + const first = new Promise((resolve) => { + firstReport = resolve; + }); + for (const id of ["slow", "fast", "also-fast"]) { + registry.register( + source({ + id, + discover: async () => [{ key: "account", input: {} }], + fetch: async () => (id === "slow" ? slow : { status: "available", windows: [] }), + }), + ); + } + let complete = false; + const request = registry + .listReports({ + onReport: (entry) => { + reports.push(entry.id); + firstReport(); + }, + }) + .then((entries) => { + complete = true; + return entries; + }); + await first; + expect(reports).toEqual(["fast:account", "also-fast:account"]); + expect(complete).toBe(false); + const entries = await request; + expect(reports).toEqual(["fast:account", "also-fast:account", "slow:account"]); + expect(entries[0]?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + finishSlow({ status: "available", windows: [] }); + expect((await registry.listReports({ reportIds: ["slow:account"] }))[0]?.report).toEqual( + entries[0]?.report, + ); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index 2d31798fbd..157bf7df4e 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,3 +1,5 @@ +import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; +import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { z } from "zod"; import type { Logger } from "pino"; import { @@ -12,7 +14,7 @@ export interface UsageSource { id: string; label: string; icon?: string; - discover(): Promise; + discover(scope: UsageScope): Promise; fetch(input: unknown): Promise; } @@ -22,10 +24,31 @@ interface KnownReport { label?: string; } +export interface AgentUsageLookup { + hasAgent(id: string): boolean; + usageSession(id: string): AgentUsageSession | null; +} + +export interface ListUsageReportsOptions { + forceRefresh?: boolean; + reportIds?: string[]; + agentId?: string; + onReport?: (report: UsageReportEntry) => void; +} + +interface AgentReports { + provider: string; + model?: string; + sessionKey: string; + reports: Map; +} + /** Owns account identity, ordered logins for each account, and the five-minute fetch cache. */ export class UsageSourceRegistry { private readonly sources = new Map(); private readonly known = new Map(); + private defaults = new Map(); + private readonly byAgent = new Map(); private readonly cache = new Map(); private readonly pending = new Map>(); @@ -33,6 +56,8 @@ export class UsageSourceRegistry { private readonly now: () => number = Date.now, private readonly ttlMs = 300_000, private readonly logger: Pick = { warn: console.warn }, + private readonly agents: AgentUsageLookup = { hasAgent: () => false, usageSession: () => null }, + private readonly deadlineMs = 20_000, ) {} register(source: UsageSource): void { @@ -42,25 +67,98 @@ export class UsageSourceRegistry { unregister(id: string): void { this.sources.delete(id); + for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key); + // Re-discover on the next session query when a source is replaced. + for (const [agentId, mapping] of this.byAgent) { + if ([...mapping.reports.values()].some((report) => report.source.id === id)) + this.byAgent.delete(agentId); + } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); } - async listReports( - options: { forceRefresh?: boolean; reportIds?: string[] } = {}, - ): Promise { - const ids = options.reportIds ?? (await this.discoverReportIds()); + async listReports(options: ListUsageReportsOptions = {}): Promise { + if (options.agentId !== undefined && options.reportIds !== undefined) + throw new Error("agentId and reportIds cannot be combined"); + this.pruneAgents(); + let ids: string[]; + if (options.agentId !== undefined) { + ids = await this.discoverAgent(options.agentId); + } else if (options.reportIds !== undefined) { + ids = options.reportIds; + } else { + this.defaults = await this.discover({ kind: "global" }); + this.mergeKnown(); + ids = [...this.known.keys()]; + } return Promise.all( [...new Set(ids)].flatMap((id) => { const known = this.known.get(id); - return known ? [this.fetchId(id, known, options.forceRefresh)] : []; + if (!known) return []; + return [ + this.fetchId(id, known, options.forceRefresh).then((entry) => { + options.onReport?.(entry); + return entry; + }), + ]; }), ); } - private async discoverReportIds(): Promise { + private pruneAgents(): void { + for (const id of this.byAgent.keys()) { + if (!this.agents.hasAgent(id)) this.byAgent.delete(id); + } + this.mergeKnown(); + } + + private async discoverAgent(agentId: string): Promise { + if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); + const session = this.agents.usageSession(agentId); + if (!session) return []; + const previous = this.byAgent.get(agentId); + const sameScope = + previous?.sessionKey === session.sessionKey && + previous.provider === session.provider && + previous.model === session.model; + if (sameScope) return [...previous.reports.keys()]; + const reports = await this.discover({ + kind: "session", + provider: session.provider, + model: session.model, + env: session.env, + }); + // A query that finishes after a resume must not publish the previous launch's mapping. + if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return []; + this.byAgent.set(agentId, { + sessionKey: session.sessionKey, + provider: session.provider, + model: session.model, + reports, + }); + this.mergeKnown(); + return [...reports.keys()]; + } + + private mergeKnown(): void { + this.known.clear(); + for (const reports of [ + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ]) { + for (const [id, report] of reports) { + if (this.sources.get(report.source.id) !== report.source) continue; + const known = this.known.get(id); + if (known) known.inputs.push(...report.inputs); + else this.known.set(id, { ...report, inputs: [...report.inputs] }); + } + } + } + + private async discover(scope: UsageScope): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { + const reports = new Map(); try { const accounts = z .array( @@ -70,31 +168,24 @@ export class UsageSourceRegistry { input: z.json(), }), ) - .parse(await source.discover()); - const knownReports = new Map(); - const ids: string[] = []; + .parse(await source.discover(scope)); for (const account of accounts) { const id = `${source.id}:${account.key}`; - ids.push(id); - const known = knownReports.get(id); + const known = reports.get(id); if (known) known.inputs.push(account.input); - else knownReports.set(id, { source, inputs: [account.input], label: account.label }); + else reports.set(id, { source, inputs: [account.input], label: account.label }); } - for (const key of this.known.keys()) { - if (key.startsWith(`${source.id}:`)) this.known.delete(key); - } - for (const [id, known] of knownReports) this.known.set(id, known); - return ids; } catch (error) { - for (const key of this.known.keys()) { - if (key.startsWith(`${source.id}:`)) this.known.delete(key); - } this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); - return []; } + return reports; }), ); - return discovered.flat(); + const merged = new Map(); + for (const reports of discovered) { + for (const [id, report] of reports) merged.set(id, report); + } + return merged; } // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. @@ -136,7 +227,7 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchWithFallback(known), + report: await this.fetchBeforeDeadline(known), }; this.writeCache(id, entry); return entry; @@ -156,6 +247,21 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } + private async fetchBeforeDeadline(known: KnownReport): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ status: "error", error: "Usage fetch timed out" }), + this.deadlineMs, + ); + }); + try { + return await Promise.race([this.fetchWithFallback(known), deadline]); + } finally { + clearTimeout(timer); + } + } + private async fetchWithFallback(known: KnownReport): Promise { let report = await this.fetchLogin(known.source, known.inputs[0]); for (const input of known.inputs.slice(1)) { diff --git a/packages/server/src/server/session/owned-subscriptions/replies.ts b/packages/server/src/server/session/owned-subscriptions/replies.ts index 2fcfc1b97e..cf8a44b3cc 100644 --- a/packages/server/src/server/session/owned-subscriptions/replies.ts +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -31,6 +31,7 @@ const exceptions = { agent_deleted: ["delete_agent_request"], agent_permission_resolved: ["agent_permission_response"], terminals_changed: ["subscribe_terminals_request"], + "usage.list_reports.update": ["usage.list_reports.request"], "daemon.update.progress": ["daemon.update.request"], // Transcription belongs to the explicitly retained voice operation, not a request ID. transcription_result: [], diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index a56d6f6c9a..2aa65992b8 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -18,7 +18,8 @@ test("lists reports from usage sources", async () => { emit: (message) => emitted.push(message), runtime: { async listUsageReports(options) { - requested.push(options); + requested.push({ forceRefresh: options.forceRefresh, reportIds: options.reportIds }); + options.onReport?.(entry); return [entry]; }, async listLegacyUsage() { @@ -31,7 +32,8 @@ test("lists reports from usage sources", async () => { await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]); expect(emitted).toEqual([ - { type: "usage.list_reports.response", payload: { requestId: "list", reports: [entry] } }, + { type: "usage.list_reports.update", payload: { requestId: "list", report: entry } }, + { type: "usage.list_reports.response", payload: { requestId: "list", error: null } }, ]); }); @@ -55,3 +57,18 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = payload: { requestId: "u1", code: "provider_usage_list_failed" }, }); }); + +test("request failures terminate with an error response and no updates", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "failed" }); + expect(emitted).toEqual([ + { + type: "usage.list_reports.response", + payload: { requestId: "failed", error: "Plugin runtime is unavailable" }, + }, + ]); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index c94eaf2fcf..500c03a088 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -1,3 +1,4 @@ +import type { ListUsageReportsOptions } from "../../plugins/usage-sources/index.js"; import type pino from "pino"; import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -5,10 +6,7 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "../../messag export interface UsageSessionOptions { emit(message: SessionOutboundMessage): void; runtime?: { - listUsageReports(options: { - forceRefresh?: boolean; - reportIds?: string[]; - }): Promise; + listUsageReports(options: ListUsageReportsOptions): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; logger: pino.Logger; @@ -22,16 +20,28 @@ export class UsageSession { ): Promise { try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const reports = await this.options.runtime.listUsageReports({ + await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, reportIds: msg.reportIds, + agentId: msg.agentId, + onReport: (report) => + this.options.emit({ + type: "usage.list_reports.update", + payload: { requestId: msg.requestId, report }, + }), }); this.options.emit({ type: "usage.list_reports.response", - payload: { requestId: msg.requestId, reports }, + payload: { requestId: msg.requestId, error: null }, }); } catch (error) { - this.emitError(msg, error, "usage_list_reports_failed"); + this.options.emit({ + type: "usage.list_reports.response", + payload: { + requestId: msg.requestId, + error: error instanceof Error ? error.message : String(error), + }, + }); } } @@ -64,20 +74,4 @@ export class UsageSession { }); } } - - private emitError( - msg: Extract, - error: unknown, - code: string, - ): void { - this.options.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: error instanceof Error ? error.message : String(error), - code, - }, - }); - } } diff --git a/plugins/claude-usage-source/index.server.ts b/plugins/claude-usage-source/index.server.ts index 82bcae58b9..4c97771bfa 100644 --- a/plugins/claude-usage-source/index.server.ts +++ b/plugins/claude-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Claude", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => discover(scope), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index f79153933f..d85401037a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -134,6 +134,7 @@ describe("Claude credential routes", () => { JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), ); const accounts = await discover( + { kind: "global" }, { home: dir, env: { CLAUDE_CONFIG_DIR: dir }, platform: "linux" }, fixtureFetch("fixture-default"), ); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 2f8119f502..75248116cc 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -29,15 +29,15 @@ async function accountIdentity( input: Parameters[0], env: NodeJS.ProcessEnv = {}, ) { - const accounts = await discover(lookup(env), profile); + const accounts = await discover({ kind: "global" }, lookup(env), profile); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } -async function logins(options: Parameters[0]) { - return (await discover(options, profile)).map( +async function logins(options: Parameters[1]) { + return (await discover({ kind: "global" }, options, profile)).map( (account) => account.input as Parameters[0], ); } @@ -204,9 +204,12 @@ test("Claude Keychain login replaces the fallback file", async () => { const inputs = await logins({ ...lookup(), platform: "darwin", + env: { USER: "fixture-user" }, readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }), }); - expect(inputs).toEqual([{ route: { store: "keychain" } }]); + expect(inputs).toEqual([ + { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" } }, + ]); }); test("Claude account metadata alone does not discover a login", async () => { @@ -221,16 +224,23 @@ test("keychain identity belongs to its token even when Claude Code metadata name oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" }, }); const [identity] = await discover( + { kind: "global" }, { ...lookup(), platform: "darwin", + env: { USER: "fixture-user" }, readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain-other" }, }), }, profile, ); - expect(identity).toEqual({ key: "pi-account.pi-org", input: { route: { store: "keychain" } } }); + expect(identity).toEqual({ + key: "pi-account.pi-org", + input: { + route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, + }, + }); }); test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => { diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index 289076a755..2a0c91235f 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -193,7 +194,9 @@ describe("claude usage source", () => { "discovers no Claude logins in %s", async (scenario) => { if (scenario === "unrelated files") writeFileSync(join(homeDir, "unrelated.json"), "{}"); - expect(await discover({ home: homeDir, env: {}, platform: "linux" })).toEqual([]); + expect( + await discover({ kind: "global" }, { home: homeDir, env: {}, platform: "linux" }), + ).toEqual([]); }, ); @@ -757,6 +760,7 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { try { const lookup = { platform: "darwin" as const, + env: { USER: "fixture", CLAUDE_CONFIG_DIR: directory }, claudeHome: directory, home: directory, readKeychainCredentials: async () => ({ @@ -765,7 +769,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { }; let usageRequested = false; await fetchUsage( - { route: { store: "keychain" } }, + { + route: { + store: "keychain", + account: "fixture", + service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`, + }, + }, async () => { usageRequested = true; return new Response(null, { status: 401 }); @@ -774,7 +784,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { ); expect(usageRequested).toBe(true); const account = await accountFor( - { route: { store: "keychain" } }, + { + route: { + store: "keychain", + account: "fixture", + service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`, + }, + }, async () => jsonResponse({ account: { uuid: "keychain-account" }, @@ -864,7 +880,10 @@ it("discovers a Pi OAuth login independently of the CLI login", async () => { anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" }, }), ); - const inputs = await discover({ home: directory, env: {}, platform: "linux" }); + const inputs = await discover( + { kind: "global" }, + { home: directory, env: {}, platform: "linux" }, + ); expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); @@ -880,7 +899,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc const lookup = { home: directory, claudeHome: directory, - env: {}, + env: { USER: "fixture" }, platform: "darwin" as const, now: () => 10_800_000, readKeychainCredentials: async () => ({ @@ -892,9 +911,14 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc calls++; throw new Error("must not call network"); }; - const accounts = await discover(lookup, network); + const accounts = await discover({ kind: "global" }, lookup, network); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { route: { store: "keychain" } } }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + input: { + route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, + }, + }, ]); expect(await fetchUsage(accounts[0]!.input as UsageInput, network, lookup)).toEqual({ status: "unavailable", @@ -912,10 +936,8 @@ async function accountFor( now: () => number, lookup: Parameters[0] = {}, ) { - const accounts = await discover({ ...lookup, now }, fetchApi); - const account = accounts.find( - (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), - ); + const accounts = await discover({ kind: "global" }, { ...lookup, now }, fetchApi); + const account = accounts.find((candidate) => isDeepStrictEqual(candidate.input, input)); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } @@ -936,7 +958,7 @@ it.each([401, 403])("fetch reports rejected usage HTTP %i", async (status) => { it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", async (status) => { const lookup = tokenLookup(`profile-rejection-${status}`); const api: typeof fetch = async () => new Response(null, { status }); - const accounts = await discover(lookup, api); + const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([ { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, ]); @@ -969,7 +991,7 @@ it.each([ if (networkError) throw new Error(error); return new Response(null, { status: 500 }); }; - const accounts = await discover(lookup, api); + const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); }, @@ -981,7 +1003,7 @@ it("re-reads a refreshed login at the original locator", async () => { join(lookup.claudeHome, ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "expiring-token", expiresAt: 0 } }), ); - const [account] = await discover(lookup, async () => { + const [account] = await discover({ kind: "global" }, lookup, async () => { throw new Error("expired token must not fetch"); }); expect(await fetchUsage(account!.input as UsageInput, fetch, lookup)).toEqual({ @@ -999,3 +1021,75 @@ it("re-reads a refreshed login at the original locator", async () => { ); expect(report.status).toBe("available"); }); + +it("session discovery reads only its Claude home and excludes foreign sessions", async () => { + const home = mkdtempSync(join(tmpdir(), "claude-session-scope-")); + try { + writeClaudeCredentials(home, "scoped-unique-token"); + const scope = { + kind: "session" as const, + provider: "claude", + env: { HOME: home, CLAUDE_CONFIG_DIR: home }, + }; + const calls: string[] = []; + const api: typeof fetch = async (url) => { + calls.push(String(url)); + return jsonResponse({ + account: { uuid: "session-account", email: "session@example.test" }, + organization: { uuid: "org" }, + }); + }; + const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} }; + const accounts = await discover(scope, lookup, api); + expect(accounts).toEqual([ + { key: "session-account.org", label: "session@example.test", input: credentialInput(home) }, + ]); + await discover(scope, lookup, api); + expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); + for (const env of [ + { CLAUDE_CODE_USE_BEDROCK: "1" }, + { CLAUDE_CODE_USE_VERTEX: "1" }, + { ANTHROPIC_BASE_URL: "https://other.test" }, + ]) { + expect(await discover({ ...scope, env: { ...scope.env, ...env } }, lookup, api)).toEqual([]); + } + expect(await discover({ ...scope, provider: "codex" }, lookup, api)).toEqual([]); + expect( + await discover( + { ...scope, env: { ...scope.env, CLAUDE_CONFIG_DIR: join(home, "missing") } }, + lookup, + api, + ), + ).toEqual([]); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +it("session Keychain input retains the selected config-directory locator through fetch", async () => { + const reads: string[] = []; + const scope = { + kind: "session" as const, + provider: "claude", + env: { HOME: "/fixture", USER: "fixture-user", CLAUDE_CONFIG_DIR: "/fixture/work" }, + }; + const service = `Claude Code-credentials-${hashAccountKey(scope.env.CLAUDE_CONFIG_DIR).slice(0, 8)}`; + const lookup = { + platform: "darwin" as const, + readKeychainCredentials: async (selected: string, account: string) => { + reads.push(`${selected}:${account}`); + return { claudeAiOauth: { accessToken: "scoped-keychain-fixture" } }; + }, + }; + const accounts = await discover(scope, lookup, async () => + jsonResponse({ account: { uuid: "keychain-scoped" }, organization: { uuid: "org" } }), + ); + expect(accounts).toHaveLength(1); + const input = inputSchema.parse(accounts[0]?.input); + expect(input.route).toEqual({ store: "keychain", service, account: "fixture-user" }); + await fetchUsage(input, async () => jsonResponse(makeClaudeResponse()), { + ...lookup, + env: { CLAUDE_CONFIG_DIR: "/somewhere-else" }, + }); + expect(reads).toEqual(Array(3).fill(`${service}:fixture-user`)); +}); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 03509bba21..b3781ba86c 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -11,6 +11,7 @@ import { toneFromUsedPct, unavailable, type UsageAccount, + type UsageScope, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -315,10 +316,11 @@ async function runSecurityCommand(args: string[]): Promise { export async function readClaudeKeychainCredentials( run: ClaudeKeychainCommandRunner = runSecurityCommand, account: string = claudeKeychainAccount(), + service: string = CLAUDE_KEYCHAIN_SERVICE, ): Promise { const lookups = [ - ["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], - ["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], + ["find-generic-password", "-a", account, "-w", "-s", service], + ["find-generic-password", "-w", "-s", service], ]; for (const args of lookups) { @@ -337,7 +339,7 @@ export async function readClaudeKeychainCredentials( } interface ClaudeCredentialLookup extends StoreLookup { - readKeychainCredentials?: () => Promise; + readKeychainCredentials?: (service: string, account: string) => Promise; claudeHome?: string; } @@ -350,27 +352,30 @@ function claudeCredentialPath(lookup: ClaudeCredentialLookup): string { async function keychainCredentialRecord( lookup: ClaudeCredentialLookup, + route = claudeKeychainRoute(lookup), ): Promise { if ((lookup.platform ?? process.platform) !== "darwin") return null; const parsed = ClaudeCredentialsSchema.safeParse( - await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(), + await (lookup.readKeychainCredentials + ? lookup.readKeychainCredentials(route.service, route.account) + : readClaudeKeychainCredentials(undefined, route.account, route.service)), ); return parsed.success ? toCredentialRecord(parsed.data) : null; } export async function discover( + scope: UsageScope, lookup: ClaudeCredentialLookup = {}, fetchApi: typeof fetch = fetch, ): Promise { - // Claude Code only reads the file when its Keychain login is empty. - const primary: UsageInput = (await keychainCredentialRecord(lookup)) - ? { route: { store: "keychain" } } - : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; - const candidates: UsageInput[] = [ - primary, - { route: { store: "pi", path: piAuthPath(lookup) } }, - ...discoverOmp(lookup).map((route) => ({ route })), - ]; + if (scope.kind === "session") + lookup = { + ...lookup, + env: scope.env, + home: scope.env.HOME || scope.env.USERPROFILE || homedir(), + }; + const candidates = + scope.kind === "global" ? await globalRoutes(lookup) : await sessionRoutes(scope, lookup); const accounts: UsageAccount[] = []; for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); @@ -395,6 +400,59 @@ export async function discover( return accounts; } +function claudeKeychainRoute(lookup: ClaudeCredentialLookup) { + const env = lookup.env ?? process.env; + const configDir = env.CLAUDE_CONFIG_DIR; + // macOS still uses Keychain with CLAUDE_CONFIG_DIR, with an entry keyed to that directory: + // https://code.claude.com/docs/en/authentication#credential-management + // Verified in @anthropic-ai/claude-code 2.1.59, package/cli.js: _c() appends the first + // 8 SHA-256 hex characters of HA() (the NFC-normalized config directory). + const suffix = configDir ? `-${hashAccountKey(configDir.normalize("NFC")).slice(0, 8)}` : ""; + return { + store: "keychain" as const, + service: `${CLAUDE_KEYCHAIN_SERVICE}${suffix}`, + account: claudeKeychainAccount(env.USER), + }; +} + +async function claudeRoute(lookup: ClaudeCredentialLookup): Promise { + const route = claudeKeychainRoute(lookup); + return (await keychainCredentialRecord(lookup, route)) + ? { route } + : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; +} + +async function globalRoutes(lookup: ClaudeCredentialLookup): Promise { + return [ + await claudeRoute(lookup), + { route: { store: "pi", path: piAuthPath(lookup) } }, + ...discoverOmp(lookup).map((route) => ({ route })), + ]; +} + +async function sessionRoutes( + scope: Extract, + lookup: ClaudeCredentialLookup, +): Promise { + if (scope.provider === "claude") { + const env = scope.env; + const foreign = + env.ANTHROPIC_BASE_URL && + env.ANTHROPIC_BASE_URL.replace(/\/+$/, "") !== "https://api.anthropic.com"; + if (enabled(env.CLAUDE_CODE_USE_BEDROCK) || enabled(env.CLAUDE_CODE_USE_VERTEX) || foreign) + return []; + return [await claudeRoute(lookup)]; + } + if (!scope.model?.startsWith("anthropic/")) return []; + if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; + if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); + return []; +} + +function enabled(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; +} + /** Re-read the selected login; the harness owns token refresh. */ export async function resolveClaudeCredentials( input: UsageInput, @@ -402,7 +460,12 @@ export async function resolveClaudeCredentials( ): Promise { const route = input.route; if (route.store === "claude") return readCredentialFile(route.path); - if (route.store === "keychain") return keychainCredentialRecord(lookup); + if (route.store === "keychain") + return keychainCredentialRecord(lookup, { + store: "keychain", + service: route.service ?? CLAUDE_KEYCHAIN_SERVICE, + account: route.account ?? claudeKeychainAccount(), + }); const oauth = await readHarness(route, lookup); return oauth ? { oauth: { accessToken: oauth.access }, expires: oauth.expires } : null; } diff --git a/plugins/claude-usage-source/shared/input.ts b/plugins/claude-usage-source/shared/input.ts index 76ff6a8795..b82c69cfde 100644 --- a/plugins/claude-usage-source/shared/input.ts +++ b/plugins/claude-usage-source/shared/input.ts @@ -9,7 +9,13 @@ export const routeSchema = z.discriminatedUnion("store", [ credentialId: z.number().int().positive(), }) .strict(), - z.object({ store: z.literal("keychain") }).strict(), + z + .object({ + store: z.literal("keychain"), + service: z.string().optional(), + account: z.string().optional(), + }) + .strict(), ]); export const inputSchema = z.object({ route: routeSchema }).strict(); export type UsageInput = z.infer; diff --git a/plugins/codex-usage-source/index.server.ts b/plugins/codex-usage-source/index.server.ts index 786bfe5486..8d38c15ebf 100644 --- a/plugins/codex-usage-source/index.server.ts +++ b/plugins/codex-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Codex", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => discover(scope), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts index 3368a95147..708c289f0f 100644 --- a/plugins/codex-usage-source/server/stores.test.ts +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -23,15 +23,15 @@ async function accountIdentity( input: Parameters[0], env: NodeJS.ProcessEnv = {}, ) { - const accounts = await discover(lookup(env)); + const accounts = await discover({ kind: "global" }, lookup(env)); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } -async function logins(options: Parameters[0]) { - return (await discover(options)).map( +async function logins(options: Parameters[1]) { + return (await discover({ kind: "global" }, options)).map( (account) => account.input as Parameters[0], ); } diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index e92c7ea2f2..8222aae315 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -217,7 +217,10 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { "openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" }, }), ); - const inputs = await discover({ home: directory, env: {}, platform: "linux" }); + const inputs = await discover( + { kind: "global" }, + { home: directory, env: {}, platform: "linux" }, + ); expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); @@ -227,16 +230,19 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { }); async function accountFor(input: CodexUsageInput) { - const accounts = await discover({ - home: fixtureHome, - env: { - CODEX_HOME: - input.route.store === "codex" - ? (await import("node:path")).dirname(input.route.path) - : fixtureHome, + const accounts = await discover( + { kind: "global" }, + { + home: fixtureHome, + env: { + CODEX_HOME: + input.route.store === "codex" + ? (await import("node:path")).dirname(input.route.path) + : fixtureHome, + }, + platform: "linux", }, - platform: "linux", - }); + ); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); @@ -250,7 +256,7 @@ test.each(["empty home", "unrelated files"])( const home = await mkdtemp(join(tmpdir(), "codex-no-login-")); try { if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}"); - expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]); + expect(await discover({ kind: "global" }, { home, env: {}, platform: "linux" })).toEqual([]); } finally { await rm(home, { recursive: true, force: true }); } @@ -379,3 +385,30 @@ test("unknown lengths and code review never get a duration from their slot", asy ], }); }); + +test("session discovery isolates CODEX_HOME and excludes foreign routes", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-session-scope-")); + try { + await writeFile( + join(home, "auth.json"), + JSON.stringify({ tokens: { access_token: "session-token", account_id: "session-account" } }), + ); + const scope = { + kind: "session" as const, + provider: "codex", + env: { HOME: home, CODEX_HOME: home }, + }; + const accounts = await discover(scope, { home: "/unused-default-home", env: {} }); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.input).toEqual(authInput(home)); + expect( + await discover({ ...scope, env: { ...scope.env, OPENAI_BASE_URL: "https://other.test" } }), + ).toEqual([]); + expect(await discover({ ...scope, provider: "claude" })).toEqual([]); + expect( + await discover({ ...scope, env: { HOME: home, CODEX_HOME: join(home, "missing") } }), + ).toEqual([]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 7d2d89402f..cf5207ff22 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -6,6 +6,7 @@ import { hashAccountKey, unavailable, type UsageAccount, + type UsageScope, toneFromUsedPct, windowFromReportedDuration, type UsageReport, @@ -57,7 +58,26 @@ interface Auth { expires?: number; } -export async function discover(lookup: StoreLookup = {}): Promise { +export async function discover( + scope: UsageScope, + lookup: StoreLookup = {}, +): Promise { + if (scope.kind === "session") + lookup = { + ...lookup, + env: scope.env, + home: scope.env.HOME || scope.env.USERPROFILE || homedir(), + }; + const candidates = scope.kind === "global" ? globalRoutes(lookup) : sessionRoutes(scope, lookup); + const present: UsageAccount[] = []; + for (const input of candidates) { + const auth = await readAuth(input, lookup); + if (auth) present.push({ ...accountIdentity(auth, input), input }); + } + return present; +} + +function globalRoutes(lookup: StoreLookup): CodexUsageInput[] { const env = lookup.env ?? process.env; const home = lookup.home ?? homedir(); const paths = [ @@ -77,12 +97,36 @@ export async function discover(lookup: StoreLookup = {}): Promise ({ route })), ); - const present: UsageAccount[] = []; - for (const input of candidates) { - const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + return candidates; +} + +function sessionRoutes( + scope: Extract, + lookup: StoreLookup, +): CodexUsageInput[] { + const env = scope.env; + const home = lookup.home ?? homedir(); + if (scope.provider === "codex") { + if (env.OPENAI_BASE_URL) return []; + return [ + { + route: { store: "codex", path: join(env.CODEX_HOME || join(home, ".codex"), "auth.json") }, + }, + ]; } - return present; + if (!scope.model?.startsWith("openai/")) return []; + if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; + if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); + if (scope.provider === "opencode") + return [ + { + route: { + store: "opencode", + path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"), + }, + }, + ]; + return []; } export async function readAuth( diff --git a/plugins/copilot-usage-source/index.server.ts b/plugins/copilot-usage-source/index.server.ts index 4f25223cb4..d5e965d759 100644 --- a/plugins/copilot-usage-source/index.server.ts +++ b/plugins/copilot-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "GitHub Copilot", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/cursor-usage-source/index.server.ts b/plugins/cursor-usage-source/index.server.ts index 078c321590..5b19939833 100644 --- a/plugins/cursor-usage-source/index.server.ts +++ b/plugins/cursor-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Cursor", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/grok-usage-source/index.server.ts b/plugins/grok-usage-source/index.server.ts index 15a6f4efd9..d3c37b9c15 100644 --- a/plugins/grok-usage-source/index.server.ts +++ b/plugins/grok-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Grok", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/kimi-usage-source/index.server.ts b/plugins/kimi-usage-source/index.server.ts index c6a9a978ff..a18da096a3 100644 --- a/plugins/kimi-usage-source/index.server.ts +++ b/plugins/kimi-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Kimi", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/minimax-usage-source/index.server.ts b/plugins/minimax-usage-source/index.server.ts index c98af742dd..a72a66c949 100644 --- a/plugins/minimax-usage-source/index.server.ts +++ b/plugins/minimax-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "MiniMax", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/opencode-go-usage-source/index.server.ts b/plugins/opencode-go-usage-source/index.server.ts index b011eaf2f4..b707c26858 100644 --- a/plugins/opencode-go-usage-source/index.server.ts +++ b/plugins/opencode-go-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "OpenCode Go", icon: "icon.svg", input: inputSchema, - discover, + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/zai-usage-source/index.server.ts b/plugins/zai-usage-source/index.server.ts index 78dfdf7fda..830afc8c29 100644 --- a/plugins/zai-usage-source/index.server.ts +++ b/plugins/zai-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Z.ai", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 7eece2810c..437741eb74 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -345,12 +345,16 @@ interface UsageAccount { input: JsonValue; } +type UsageScope = + | { kind: "global" } + | { kind: "session"; provider: string; model?: string; env: Record }; + interface UsageSourceRegistration { id: string; label: string; icon?: string; input: ZodType; - discover(): Promise; + discover(scope: UsageScope): Promise; fetch(input: unknown): Promise; } @@ -371,10 +375,28 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -Return every account whose login exists on the machine from `discover()`, including expired -logins. Return `[]` when none exists. Discovery takes no arguments and must work independently of -agent sessions and provider names. Inputs name credential stores; never put credentials in inputs -or reports. Paseo validates each input against your schema before calling `fetch()`. +`discover({ kind: "global" })` queries machine login stores, including expired logins. Session +scope queries only the login stores selected by that harness's resolved launch environment. +Return `[]` when no login exists or the session does not use your source. Never scan default stores +from session discovery or scan agents from global discovery. Discovery is a query, with no agent +lifecycle hooks. Closed agents have no session scope until resumed. + +Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each +input against your schema before calling `fetch()`. The same key in any scope is the same report; +agents sharing an account share the fetch cache. Fetches have a 20-second deadline. + +Built-in session routes: + +| Source | Session | Login store | +| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | +| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | +| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | +| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | +| Other sources | Any | No session discovery | + +Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions +with `OPENAI_BASE_URL` set. Use a stable account key: 1–128 characters from `[A-Za-z0-9._-]`. It identifies the account or organization whose quota is metered and survives token rotation. Never use a credential or raw From ed87679c67fde90e8ba9e07e5a14eae1f0f6be0c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:16:11 +0100 Subject: [PATCH 020/281] fix(nix): ship the OpenCode v2 bridge plugin in the daemon closure (#5988) The Nix package copies only what scripts/trace-daemon.mjs lists. The list named the v1 bridge bundle but not opencode/v2/bridge-plugin.bundle.mjs, so creating an OpenCode v2 agent from the Nix package failed with "Bundled OpenCode bridge plugin artifact is missing". List every bridge bundle under opencode/, and add a dist test that installs the traced closure the way nix/package.nix does and loads the bridge plugin for each OpenCode version from it. Closes #5926 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- scripts/trace-daemon-dist.test.mjs | 46 ++++++++++++++++++++++++++++++ scripts/trace-daemon.mjs | 5 ++-- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 scripts/trace-daemon-dist.test.mjs diff --git a/scripts/trace-daemon-dist.test.mjs b/scripts/trace-daemon-dist.test.mjs new file mode 100644 index 0000000000..a2c2149daa --- /dev/null +++ b/scripts/trace-daemon-dist.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const bridgeModule = "packages/server/dist/server/server/agent/providers/opencode/bridge.js"; + +// Mirrors nix/package.nix's installPhase: copy every traced path into $out. +async function installTracedDaemon(outRoot) { + const { stdout } = await promisify(execFile)( + process.execPath, + [path.join(repoRoot, "scripts/trace-daemon.mjs")], + { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 }, + ); + for (const file of stdout.split("\n").filter(Boolean)) { + await cp(path.join(repoRoot, file), path.join(outRoot, file), { + recursive: true, + verbatimSymlinks: true, + }); + } +} + +test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode version", async () => { + const outRoot = await mkdtemp(path.join(os.tmpdir(), "trace-daemon-dist-")); + try { + await installTracedDaemon(outRoot); + const installedBridgeUrl = pathToFileURL(path.join(outRoot, bridgeModule)).href; + const { loadOpenCodeBridgePluginArtifact } = await import(installedBridgeUrl); + + for (const version of [1, 2]) { + const artifact = await loadOpenCodeBridgePluginArtifact( + installedBridgeUrl, + undefined, + version, + ); + assert.ok(artifact.byteLength > 0, `OpenCode v${version} bridge plugin is empty`); + } + } finally { + await rm(outRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb7408571f..cb80266c13 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -58,8 +58,9 @@ const additionalInputs = [ "packages/server/dist/server/terminal/shell-integration/**", // Silero VAD ONNX model (sherpa speech provider) "packages/server/dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx", - // OpenCode loads this plugin from a content-addressed runtime copy. - "packages/server/dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", + // OpenCode loads these plugins from a content-addressed runtime copy, one + // bundle per supported OpenCode version (opencode/ and opencode/v2/). + "packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs", // Server runtime config files (read by path, not require) "packages/server/.env.example", // CLI shebang script wrapping dist/index.js From 4869214bcb42abd16add6fae00bdbda99c7a1e9b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:58:22 +0200 Subject: [PATCH 021/281] fix(cli): confirm daemon restart when provider probes are slow (#6009) paseo daemon restart gave each daemon status read one second. Status probes every provider binary, which takes longer than that on machines with several providers installed, so restart kept retrying until its deadline and reported RESTART_NOT_CONFIRMED after the worker had already been replaced. The status read now gets the remaining restart deadline. Connecting to the replacement keeps its one-second bound, so polling a worker that is not up yet stays responsive. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/commands/daemon/lifecycle.e2e.test.ts | 31 +++++++++++++++++++ packages/cli/src/commands/daemon/restart.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index 9de3cf6bb6..dfffe1f61b 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -429,6 +429,37 @@ test("empty explicit selectors never select the ambient daemon or create local s } }, 60_000); +test.skipIf(process.platform === "win32")( + "restart confirms the replacement worker when a provider probe is slow", + async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const provider = path.join(f.root, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), 1800);\n`, + { mode: 0o700 }, + ); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.agents = { + ...config.agents, + providers: { claude: { command: { mode: "replace", argv: [provider] } } }, + }; + await writeFile(configPath, JSON.stringify(config)); + const launch = await f.ok(["start", "--home", home, "--timeout", "30"]); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]); + expect(restarted.supervisorPid).toBe(launch.pid); + expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid); + } finally { + await f.close(); + } + }, + 60_000, +); + test("IPv6 publication supports home-selected query and worker restart", async () => { const f = await fixture(); const home = f.homes[0]!; diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 1fdbd7137c..ef88e39a23 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -73,7 +73,8 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma try { if (replacement.getLastServerInfoMessage()?.serverId !== serverId) throw new Error("Connected peer identity changed"); - const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) }); + // Status probes every provider, so it can take seconds on a healthy worker. + const status = await replacement.getDaemonStatus({ timeout: remaining() }); if (status.pid !== workerPid) { await checkSupervisor(); return { From 79a3942393a5f3a3d4e9f9c335474a07db80ba84 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:10:19 +0200 Subject: [PATCH 022/281] fix(server): mark Claude terminals as needing input when Claude goes idle (#6022) Claude Code reports the kind of notification in `notification_type`, but the hook parser only read `matcher` and `reason`, which Claude never sends in Notification input. Every idle_prompt notification resolved to nothing, so a Claude terminal waiting for the user never showed as needing input. Closes #5845 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/terminal-activity.md | 2 +- packages/cli/src/commands/hooks.test.ts | 15 ++++++++++++--- .../src/terminal/agent-hooks/claude/claude.ts | 4 ++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index 42826e5f49..ee1f411a7a 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -51,7 +51,7 @@ Claude hook mapping: - `UserPromptSubmit` → `running` - `Stop`, `StopFailure`, `SessionEnd` → `idle` -- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input` +- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input` Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input while terminal activity is working clears the activity without finished attention. The same diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts index 3f8c31d855..dd99816b06 100644 --- a/packages/cli/src/commands/hooks.test.ts +++ b/packages/cli/src/commands/hooks.test.ts @@ -17,6 +17,15 @@ function inputFrom(value: string) { }; } +function claudeNotification(notificationType: string) { + return JSON.stringify({ + session_id: "session-1", + hook_event_name: "Notification", + message: "Claude is waiting for your input", + notification_type: notificationType, + }); +} + function ttyInput() { return { isTTY: true, @@ -123,7 +132,7 @@ describe("runHooksCommand", () => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom('{"reason":"idle_prompt"}'), + inputFrom(claudeNotification("idle_prompt")), ); expectPostedState(send, "needs-input"); @@ -131,11 +140,11 @@ describe("runHooksCommand", () => { it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])( "ignores Claude %s notifications", - async (reason) => { + async (notificationType) => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom(JSON.stringify({ reason })), + inputFrom(claudeNotification(notificationType)), ); expect(send.calls).toEqual([]); diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.ts b/packages/server/src/terminal/agent-hooks/claude/claude.ts index 35bfa9eca5..82ddcf770d 100644 --- a/packages/server/src/terminal/agent-hooks/claude/claude.ts +++ b/packages/server/src/terminal/agent-hooks/claude/claude.ts @@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean { try { const notification = JSON.parse(raw) as unknown; if (!notification || typeof notification !== "object") return false; - const payload = notification as { matcher?: unknown; reason?: unknown }; - return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt"; + const payload = notification as { notification_type?: unknown }; + return payload.notification_type === "idle_prompt"; } catch { return false; } From 8216e86ecb92e9b6a39422b12dde8b70a619483e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:55:21 +0200 Subject: [PATCH 023/281] fix(cli): delete archived agents with agent delete --all and --cwd (#6027) * fix(cli): delete archived agents in bulk commands * test(cli): cover archived agents in bulk delete from the CLI suite The daemon-backed test imported server test utilities from a CLI test, which the CI contract in scripts/ci-workflow.test.mjs forbids. Cover --all and --cwd against archived agents in the runDeleteCommand unit test instead. * fix(cli): read every history page when bulk-deleting agents agent delete --all and --cwd read one page of fetch_agents, so agents past the first 200 were skipped, and fetch_agents leaves out saved agents whose provider is unavailable. List agents from the paged agent history, which the History view uses, so bulk delete covers both. * fix(cli): delete an agent by ID without listing history --------- Co-authored-by: 3ae3ae Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../cli/src/commands/agent/delete.test.ts | 66 ++++++++++++++++++- packages/cli/src/commands/agent/delete.ts | 34 ++++++---- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 60d5cec50f..b9d00cd5ef 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; @@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js"; const agent = { id: "11111111-1111-4111-8111-111111111111", status: "running", - archivedAt: null, + archivedAt: null as string | null, cwd: "/tmp/project", }; +const archivedAgent = { + id: "22222222-2222-4222-8222-222222222222", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/project/nested", +}; +const archivedOutsideAgent = { + id: "33333333-3333-4333-8333-333333333333", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/elsewhere", +}; +let daemonAgents: (typeof agent)[] = [agent]; +const toEntry = (entry: typeof agent) => ({ agent: entry }); +// The fake daemon pages history two agents at a time. +const historyPage = (cursor: string | undefined) => { + const start = Number(cursor ?? 0); + const end = start + 2; + return { + entries: daemonAgents.slice(start, end).map(toEntry), + pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null }, + }; +}; const cancelAgent = vi.fn(async () => { throw new Error("active run cancellation was not acknowledged"); }); +const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) => + historyPage(options.page.cursor), +); const deleteAgent = vi.fn(async () => undefined); const close = vi.fn(async () => undefined); vi.mock("../../utils/client.js", () => ({ connectToDaemon: vi.fn(async () => ({ - fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })), + fetchAgents: vi.fn(async () => historyPage(undefined)), + fetchAgentHistory, fetchAgent: vi.fn(async () => ({ agent })), cancelAgent, deleteAgent, @@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({ })); describe("runDeleteCommand", () => { + beforeEach(() => { + daemonAgents = [agent]; + deleteAgent.mockClear(); + fetchAgentHistory.mockClear(); + }); + it("force-deletes a running agent when graceful cancellation is refused", async () => { const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); + expect(fetchAgentHistory).not.toHaveBeenCalled(); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); expect(result.data).toEqual({ @@ -38,4 +72,30 @@ describe("runDeleteCommand", () => { agentIds: [agent.id], }); }); + + it("deletes archived agents on every history page with --all", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never); + + expect(result.data).toEqual({ + deletedCount: 3, + agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id], + }); + }); + + it("deletes archived agents inside the directory with --cwd", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand( + undefined, + { daemonTarget, cwd: "/tmp/project" }, + {} as never, + ); + + expect(result.data).toEqual({ + deletedCount: 2, + agentIds: [agent.id, archivedAgent.id], + }); + }); }); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index f1499b51f4..5e48751bb8 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; +import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client"; export function addDeleteOptions(cmd: Command): Command { return cmd @@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions { export type AgentDeleteResult = SingleResult; +// History includes archived agents and agents whose provider is unavailable. +async function fetchHistoryAgents(client: DaemonClient) { + const agents: FetchAgentHistoryEntry["agent"][] = []; + let cursor: string | undefined; + do { + const payload = await client.fetchAgentHistory({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); + return agents; +} + export async function runDeleteCommand( id: string | undefined, options: AgentDeleteOptions, @@ -50,19 +65,16 @@ export async function runDeleteCommand( const client = await connectToDaemon({ target: options.daemonTarget }); try { - const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); - let agents = fetchPayload.entries.map((entry) => entry.agent); const deletedIds: string[] = []; + let agents: FetchAgentHistoryEntry["agent"][]; - if (options.all) { - agents = agents.filter((a) => !a.archivedAt); - } else if (options.cwd) { - agents = agents.filter((a) => { - if (a.archivedAt) return false; - return isSameOrDescendantPath(options.cwd!, a.cwd); - }); - } else if (id) { - const fetchResult = await client.fetchAgent({ agentId: id }); + if (options.all || options.cwd) { + agents = await fetchHistoryAgents(client); + if (!options.all) { + agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd)); + } + } else { + const fetchResult = await client.fetchAgent({ agentId: id! }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", From cc8fe41e2828a34d4d9706c0b8353c0e91987a25 Mon Sep 17 00:00:00 2001 From: Jared <77949461+ThePharmer@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:15:14 -0400 Subject: [PATCH 024/281] Send display: summarized so Claude thinking renders (#4355) The Agent SDK request never set thinking.display, so on Opus 4.7 and later the API default of "omitted" applied and every thinking block arrived with empty text. Paseo drops empty blocks, so Claude agents never produced reasoning timeline items. Closes #4342 --- .../src/server/agent/providers/claude/agent.test.ts | 4 ++-- .../src/server/agent/providers/claude/agent.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index dbd10aea4d..d28ac910ac 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1009,7 +1009,7 @@ describe("ClaudeAgentSession features", () => { expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ effort: "xhigh", - thinking: { type: "adaptive" }, + thinking: { type: "adaptive", display: "summarized" }, settings: { ultracode: true }, }); @@ -1223,7 +1223,7 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], - ["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"], + ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], ["provider default", null, undefined, undefined], ])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 15eeb1500a..36f991d019 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -3244,10 +3244,18 @@ class ClaudeAgentSession implements AgentSession { return { thinking: { type: "disabled" }, effort: undefined, ultracode: false }; } if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) { - return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + ultracode: true, + }; } if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: thinkingOptionId, + ultracode: false, + }; } return { thinking: undefined, effort: undefined, ultracode: false }; } From ed48655a777577a8ab3dd4e6f61a6cec64555413 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:18:19 +0200 Subject: [PATCH 025/281] fix(server): stop directory search from taking every filesystem thread (#6029) Each pending fs call holds one of libuv's four shared threadpool threads, and a read on a hung mount, a protected macOS folder, or a huge network directory does not return. Add Project starts a new search per keystroke and nothing cancels the old ones, so four stuck searches took every thread: logging stopped and `paseo project create` timed out until the read returned or the daemon was killed. Directory search now runs its filesystem calls through one shared limit of two, leaving the rest of the pool to the daemon. Refs #4879 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com> --- .../src/utils/directory-suggestions.test.ts | 55 +++++++++++++++++++ .../server/src/utils/directory-suggestions.ts | 23 +++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index c7d4126af1..9d9015aed0 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,3 +1,4 @@ +import { createHook } from "node:async_hooks"; import { execFileSync } from "node:child_process"; import { mkdtempSync, @@ -1024,3 +1025,57 @@ describe("home-tree scan cost", () => { }, ); }); + +// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that +// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up +// while the user types must not be able to take every thread from the rest of the daemon. +describe("filesystem threads held by concurrent searches", () => { + const SEARCHES = 8; + let scanRoot: string; + + beforeEach(() => { + scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-"))); + for (let branch = 0; branch < 20; branch += 1) { + mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true }); + } + }); + + afterEach(() => { + rmSync(scanRoot, { recursive: true, force: true }); + }); + + async function peakPendingFilesystemRequests(run: () => Promise): Promise { + const pending = new Set(); + let peak = 0; + const hook = createHook({ + init(asyncId, type) { + if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return; + pending.add(asyncId); + peak = Math.max(peak, pending.size); + }, + before(asyncId) { + pending.delete(asyncId); + }, + }); + hook.enable(); + try { + await run(); + } finally { + hook.disable(); + } + return peak; + } + + function searchWhileTyping() { + const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`); + return Promise.all( + queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })), + ); + } + + it("leaves threadpool threads free for the rest of the daemon", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBeLessThanOrEqual(2); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 35e8f5154b..39baf3a44c 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,6 +1,7 @@ import type { Dirent, Stats } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import path from "node:path"; +import pLimit from "p-limit"; import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match"; import { isPathInsideRoot } from "./path.js"; import { runGitCommand } from "./run-git-command.js"; @@ -102,6 +103,24 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_MATCH_TIER = 5; + +// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on +// a hung mount or a protected folder never returns. Searches pile up while the user types, so they +// share two threads and leave the rest of the pool to the daemon. +const filesystemCalls = pLimit(2); + +function readdir(directory: string): Promise { + return filesystemCalls(() => fs.readdir(directory, { withFileTypes: true })); +} + +function realpath(target: string): Promise { + return filesystemCalls(() => fs.realpath(target)); +} + +function stat(target: string): Promise { + return filesystemCalls(() => fs.stat(target)); +} + export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", @@ -655,7 +674,7 @@ async function readChildren(directory: string): Promise { ) { rawEntries = cached.entries; } else { - const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]); + const dirents = await readdir(directory).catch(() => [] as Dirent[]); rawEntries = dirents .map(toRawChildEntry) .filter((entry): entry is RawChildEntry => entry !== null) From 485221b739097806c2a80d97030128e038d05f01 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 10:55:25 +0100 Subject: [PATCH 026/281] fix(app): enlarge compact sidebar footer tap targets (#6044) * fix(app): enlarge compact sidebar footer tap targets The compact drawer reused 28px desktop buttons, making footer actions hard to tap. Use 44px targets with larger glyphs in compact layouts while preserving desktop geometry. * test(app): wait for compact footer tap stability --- packages/app/e2e/browser/sidebar-help.spec.ts | 32 +++++++++++++++++++ packages/app/src/components/left-sidebar.tsx | 24 ++++++++------ .../components/sidebar/sidebar-help-menu.tsx | 15 +++++---- packages/app/src/styles/theme.ts | 1 + 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts index 11c7cc52b7..6d2ed6e96f 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as test.describe("compact sidebar help", () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({ + page, + }) => { + await gotoAppShell(page); + await openCompactSidebar(page); + await expectFingerSizedFooterTargets(page); + await tapHelpNearTargetEdge(page); + await expect(page.getByTestId("sidebar-help-menu")).toBeVisible(); + await page.getByTestId("sidebar-help-diagnostics").click(); + await expectDiagnosticReport(page); + }); + test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => { await gotoAppShell(page); await page.getByRole("button", { name: "Open menu", exact: true }).click(); @@ -165,3 +177,23 @@ test.describe("compact sidebar help", () => { await expectDiagnosticReport(page); }); }); + +async function openCompactSidebar(page: Page): Promise { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible(); +} + +async function expectFingerSizedFooterTargets(page: Page): Promise { + const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button"); + await expect(buttons).toHaveCount(5); + for (const button of await buttons.all()) { + const bounds = await button.boundingBox(); + expect(bounds?.width).toBeGreaterThanOrEqual(44); + expect(bounds?.height).toBeGreaterThanOrEqual(44); + } +} + +async function tapHelpNearTargetEdge(page: Page): Promise { + const button = page.getByRole("button", { name: "Help and support", exact: true }); + await button.tap({ position: { x: 40, y: 40 } }); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 0737634a1b..a6def126b1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -26,6 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; @@ -275,7 +276,7 @@ function FooterIconButton({ testID, label, icon: Icon, - iconSize, + iconSizeAdjustment = 0, shortcutKeys, theme, }: { @@ -284,17 +285,20 @@ function FooterIconButton({ label: string; icon: typeof FolderPlus; /** Only for a glyph that reads larger than the others at the same size. */ - iconSize?: number; + iconSizeAdjustment?: number; shortcutKeys?: ReturnType; theme: SidebarTheme; buttonRef?: RefObject; }) { + const isCompact = useIsCompactFormFactor(); + const iconSize = isCompact ? theme.iconSize.xl : theme.iconSize.md; + return ( {({ hovered }) => ( )} @@ -367,7 +371,7 @@ function SidebarHostPicker({ label={label} icon={Server} // Server's two boxes fill more of the square than the other glyphs. - iconSize={theme.iconSize.md - 1} + iconSizeAdjustment={-1} theme={theme} /> @@ -923,7 +927,7 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: 1, borderTopColor: theme.colors.border, }, - // The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs. + // Buttons sit edge to edge; their own inset spaces the glyphs. sidebarFooter: { flexDirection: "row", alignItems: "center", @@ -940,14 +944,14 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1.5], gap: 2, }, - footerIconButton: { - width: 28, - height: 28, + footerIconButton: (isCompact: boolean) => ({ + width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, + height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], - }, + }), tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index d709b3d9ea..85acc8cf30 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -14,6 +14,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; @@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) { } export function SidebarHelpMenu() { + const isCompact = useIsCompactFormFactor(); const { t } = useTranslation(); const shortcutsAvailable = useKeyboardShortcutsAvailable(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); @@ -103,14 +106,14 @@ export function SidebarHelpMenu() { {({ hovered }) => ( )} @@ -181,14 +184,14 @@ export function SidebarHelpMenu() { } const styles = StyleSheet.create((theme) => ({ - trigger: { - width: 28, - height: 28, + trigger: (isCompact: boolean) => ({ + width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, + height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], - }, + }), tooltipText: { fontSize: theme.fontSize.base, color: theme.colors.popoverForeground, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 04b1e8f6ae..d5c1678749 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -595,6 +595,7 @@ export const ICON_SIZE = { sm: 14, md: 16, lg: 20, + xl: 24, } as const; export const ICON_STROKE_WIDTH = 1.5; From 409b8f9312ae9e2e0b4a0ae086f64d1fa2abc6bb Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:20:44 +0200 Subject: [PATCH 027/281] fix(server): keep a new worktree open when its paseo.json cannot be parsed (#6036) * fix(server): keep workspaces available after setup config errors * refactor(server): drop the archive dependency from worktree setup Setup no longer archives a workspace when it fails, so the setup workflows no longer take archiveWorkspaceRecord. Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> * test(server): stop passing the removed archive dependency to the worktree workflow * test(server): check paseo.json survives without comparing line endings Git checks the fixture out with CRLF on Windows, so comparing its exact contents failed there. --------- Co-authored-by: 3ae3ae Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/workspace-setup-streaming.spec.ts | 35 +++++++++++++++++++ .../src/server/agent/mcp-server.test.ts | 1 - packages/server/src/server/bootstrap.ts | 1 - packages/server/src/server/session.ts | 2 -- .../src/server/worktree-session.test.ts | 26 ++------------ .../server/src/server/worktree-session.ts | 9 ----- 6 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 875ec5197d..7fe8ce1814 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => { } }); + test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-invalid-config-", { + files: [{ path: "paseo.json", content: "{ invalid json\n" }], + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const failed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "failed", + ); + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-invalid-config", + }); + const failedPayload = await failed; + expect(failedPayload.error).toContain("Failed to parse paseo.json"); + expect(failedPayload.detail.commands).toEqual([]); + + await openHomeWithProject(page, repo.path); + await navigateToWorkspaceViaSidebar(page, workspace.id); + await expectFailedSetupTabSeededInMainPane(page, workspace.id); + await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible(); + await closeSetupTab(page, workspace.id); + await clickNewChat(page); + await expectComposerVisible(page); + await openFileExplorer(page); + await expectExplorerEntryVisible(page, "paseo.json"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + daemonTest("emits a completed empty snapshot when no setup commands exist", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-none-"); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index dbe0885d5c..ad2c4babd8 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -806,7 +806,6 @@ function createPaseoWorktreeForMcpTest(options: { emit: () => {}, sessionLogger: createTestLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8ace188b51..3f49f41a19 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1170,7 +1170,6 @@ export async function createPaseoDaemon( emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, serviceProxy, scriptRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4e57ee0578..3e6813e148 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7253,7 +7253,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, @@ -7300,7 +7299,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index f28718c0f5..744ba0726a 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -115,7 +115,6 @@ function createWorkflowForRequestTest(options: { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -489,7 +488,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => emitted.push(message), sessionLogger: createLogger(), terminalManager: createTerminalManagerStub().manager, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -540,7 +538,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => workspaceSetupEvents.push(message), sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -714,7 +711,6 @@ describe("runWorktreeSetupInBackground", () => { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, }, { requestCwd: sourceWorkspaceCwd, @@ -759,7 +755,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -770,7 +765,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -828,11 +822,10 @@ describe("runWorktreeSetupInBackground", () => { }); expect(terminalManager.terminals).toHaveLength(0); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42"); }); - test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => { + test("keeps the workspace available and emits a failed snapshot when setup cannot start", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -857,7 +850,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); const workspaceId = "ws-broken-feature"; await runWorktreeSetupInBackground( @@ -869,7 +861,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -901,7 +892,8 @@ describe("runWorktreeSetupInBackground", () => { status: "failed", error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/), }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); + expect(existsSync(worktreePath)).toBe(true); + expect(existsSync(path.join(worktreePath, "paseo.json"))).toBe(true); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId); }); @@ -932,7 +924,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -943,7 +934,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1056,7 +1046,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1067,7 +1056,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1116,7 +1104,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44"); }); @@ -1151,7 +1138,6 @@ describe("runWorktreeSetupInBackground", () => { }, }); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1162,7 +1148,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1202,7 +1187,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45"); }); @@ -1233,7 +1217,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1244,7 +1227,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1265,7 +1247,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46"); }); @@ -1427,7 +1408,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message: SessionOutboundMessage) => emitted.push(message), sessionLogger: createLogger(), terminalManager: terminalManager.manager, - archiveWorkspaceRecord: vi.fn(async () => {}), serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index c693bc5380..2b9f8a0ffd 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -108,7 +108,6 @@ interface CreatePaseoWorktreeInBackgroundDependencies { emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; - archiveWorkspaceRecord: (workspaceId: string) => Promise; serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; getDaemonTcpPort: (() => number | null) | null; @@ -825,7 +824,6 @@ export async function runWorktreeSetupInBackground( ): Promise { let worktree: WorktreeConfig = options.worktree; let setupResults: WorktreeSetupCommandResult[] = []; - let setupStarted = false; const progressAccumulator = createWorktreeSetupProgressAccumulator(); const workspaceId = options.workspaceId; @@ -862,7 +860,6 @@ export async function runWorktreeSetupInBackground( const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); if (setupCommands.length === 0) { - setupStarted = true; emitSetupProgress("completed", null); } else { const runtimeEnv = await resolveWorktreeRuntimeEnv({ @@ -874,7 +871,6 @@ export async function runWorktreeSetupInBackground( cwd: workspaceCwd, env: runtimeEnv, }); - setupStarted = true; setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -906,10 +902,6 @@ export async function runWorktreeSetupInBackground( const message = error instanceof Error ? error.message : String(error); emitSetupProgress("failed", message); - if (!setupStarted) { - await dependencies.archiveWorkspaceRecord(options.workspaceId); - } - dependencies.sessionLogger.error( { err: error, @@ -917,7 +909,6 @@ export async function runWorktreeSetupInBackground( repoRoot: options.repoRoot, worktreeSlug: worktree.branchName, worktreePath: worktree.worktreePath, - setupStarted, }, "Background worktree setup failed", ); From 5285b7e502714f2d08a220c328aedca3b9637b94 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:11:40 +0200 Subject: [PATCH 028/281] fix(server): keep a stuck directory search from blocking every other search (#6041) Directory search shares two filesystem slots across all searches. Searches over one tree walk it in the same order, so two Add Project searches that reach the same folder whose read never returns held both slots, and every later directory search queued behind them: command center file search, @-file autocomplete, and file-link lookups in unrelated folders all hung until the read returned. Searches waiting on the same path now share its pending filesystem call, so one stuck folder holds one slot and the other stays free. Refs #4879 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/utils/directory-suggestions.test.ts | 8 +++++++ .../server/src/utils/directory-suggestions.ts | 21 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 9d9015aed0..68a2693562 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1078,4 +1078,12 @@ describe("filesystem threads held by concurrent searches", () => { expect(peak).toBeLessThanOrEqual(2); }); + + // Searches over one tree walk it in the same order, so searches stuck on one unreadable folder + // must hold one thread between them and leave the other for unrelated searches. + it("shares one filesystem request among searches waiting on the same path", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBe(1); + }); }); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 39baf3a44c..049b1011ac 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -106,19 +106,32 @@ const NO_MATCH_TIER = 5; // Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on // a hung mount or a protected folder never returns. Searches pile up while the user types, so they -// share two threads and leave the rest of the pool to the daemon. +// share two threads and leave the rest of the pool to the daemon. Searches over one tree walk it +// in the same order and stop at the same stuck folder, so they share its pending call and hold one +// thread between them instead of both. const filesystemCalls = pLimit(2); +const pendingFilesystemCalls = new Map>(); + +function sharedFilesystemCall(key: string, call: () => Promise): Promise { + const pending = pendingFilesystemCalls.get(key); + if (pending) return pending as Promise; + const started = filesystemCalls(call).finally(() => pendingFilesystemCalls.delete(key)); + pendingFilesystemCalls.set(key, started); + return started; +} function readdir(directory: string): Promise { - return filesystemCalls(() => fs.readdir(directory, { withFileTypes: true })); + return sharedFilesystemCall(`readdir:${directory}`, () => + fs.readdir(directory, { withFileTypes: true }), + ); } function realpath(target: string): Promise { - return filesystemCalls(() => fs.realpath(target)); + return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target)); } function stat(target: string): Promise { - return filesystemCalls(() => fs.stat(target)); + return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target)); } export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ From 05b074764dd1be4b7b04c7ab403ebeb88393aee3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:55:22 +0200 Subject: [PATCH 029/281] fix(cli): report an unknown agent ID as AGENT_NOT_FOUND (#6053) * fix(server): report an unknown agent as not found to CLI commands fetch_agent_request answered an unknown agent ID with an error string, so DaemonClient.fetchAgent threw instead of returning null. Every CLI command that looks up one agent checks for null to report AGENT_NOT_FOUND, so delete, stop, inspect, logs and the rest reported their generic failure code and skipped the "paseo ls" hint. The daemon now answers an unknown agent with a null agent and no error. An empty or ambiguous identifier is still an error. * test(cli): expect the not-found message agent logs now prints * test(cli): move the unknown agent ID cases into the delete and stop suites --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/commands/daemon/lifecycle.e2e.test.ts | 6 ++--- packages/cli/tests/07-agent-stop.test.ts | 23 +++++++++++++++++++ packages/cli/tests/27-agent-delete.test.ts | 23 +++++++++++++++++++ packages/server/src/server/session.ts | 23 +++++++++++-------- .../src/server/session.workspaces.test.ts | 2 +- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index dfffe1f61b..cb42c0b0a5 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -146,7 +146,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1); const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned); - expect(stream.stderr).toContain("Agent not found: missing-agent"); + expect(stream.stderr).toContain("No agent found matching: missing-agent"); const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned); expect(restart.supervisorPid).toBe(launchB.pid); expect(restart.workerPid).not.toBe(beforeB.workerPid); @@ -503,7 +503,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy const launchA = await f.ok(["start", "--home", a]); await f.ok(["start", "--home", b]); const absent = await f.run(["logs", agentId, "--home", a]); - expect(absent.stderr).toContain("Agent not found"); + expect(absent.stderr).toContain(`No agent found matching: ${agentId}`); follower = spawn( process.execPath, [cli, "logs", agentId, "--follow", "--tail", "0", "--home", b], @@ -522,7 +522,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy errors += data; }); await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;"); - expect(errors).not.toContain("Agent not found"); + expect(errors).not.toContain("No agent found matching"); } finally { if (follower && follower.exitCode === null) { const exited = new Promise((resolve) => follower!.once("exit", resolve)); diff --git a/packages/cli/tests/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 81b0cba66c..9416884a7b 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert"; import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -129,6 +130,28 @@ try { assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with stop\n"); } + + { + console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "stop", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }); diff --git a/packages/cli/tests/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts index bec523f9ca..349125109b 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -13,6 +13,7 @@ import assert from "node:assert"; import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -112,6 +113,28 @@ try { assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with delete\n"); } + + { + console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "delete", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3e6813e148..d57333da05 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5216,10 +5216,10 @@ export class Session { private async resolveAgentIdentifier( identifier: string, - ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> { + ): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> { const trimmed = identifier.trim(); if (!trimmed) { - return { ok: false, error: "Agent identifier cannot be empty" }; + return { ok: false, notFound: false, error: "Agent identifier cannot be empty" }; } const stored = await this.agentStorage.list(); @@ -5243,6 +5243,7 @@ export class Session { if (prefixMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches .slice(0, 5) .map((id) => id.slice(0, 8)) @@ -5257,6 +5258,7 @@ export class Session { if (titleMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent title "${trimmed}" is ambiguous (${titleMatches .slice(0, 5) .map((r) => r.id.slice(0, 8)) @@ -5264,7 +5266,7 @@ export class Session { }; } - return { ok: false, error: `Agent not found: ${trimmed}` }; + return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` }; } private async getAgentPayloadById(agentId: string): Promise { @@ -7574,11 +7576,17 @@ export class Session { } private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { + // An unknown agent is a null agent, not an error. Errors are for empty or ambiguous identifiers. const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ type: "fetch_agent_response", - payload: { requestId, agent: null, project: null, error: resolved.error }, + payload: { + requestId, + agent: null, + project: null, + error: resolved.notFound ? null : resolved.error, + }, }); return; } @@ -7587,12 +7595,7 @@ export class Session { if (!agent) { this.emit({ type: "fetch_agent_response", - payload: { - requestId, - agent: null, - project: null, - error: `Agent not found: ${resolved.agentId}`, - }, + payload: { requestId, agent: null, project: null, error: null }, }); return; } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1be52bc997..001966ab39 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -3466,7 +3466,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = session.resolveAgentIdentifier = async (identifier: string) => identifier === "Archived History Agent" ? { ok: true, agentId: agent.id } - : { ok: false, error: `Agent not found: ${identifier}` }; + : { ok: false, notFound: true, error: `Agent not found: ${identifier}` }; session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); session.buildProjectPlacementForWorkspaceId = async () => ({ projectKey: "proj-history-detail", From 30fb66c8cbb87c365592456eee67f2d925cf355c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 14:55:24 +0100 Subject: [PATCH 030/281] fix(app): preserve mobile sidebar scroll position when reopening (#6058) * fix(app): preserve mobile sidebar scroll position when reopening Keep hidden native overlays laid out so Android touch cancellation cannot spring retained scroll offsets through a zero scroll range. Hide and gate touch input on the UI thread to preserve panel motion during JS stalls. * docs(app): quote sidebar replay workspace examples * test(website): read plugin HTML without waiting for remote images * test(website): exercise built worker in browser checks The ordered plugin spec reproduces a no-response navigation timeout against a cold development server. Build and preview the published worker for E2E checks; preserve the original content and metadata assertions. --- docs/mobile-panels.md | 6 ++- docs/mobile-testing.md | 20 +++++++ ...tive-sidebar-scroll-retention.android.yaml | 4 ++ .../app/maestro/sidebar-scroll-retention.yaml | 53 +++++++++++++++++++ .../app/src/mobile-panels/presentation.tsx | 15 ++++-- packages/website/playwright.config.ts | 2 +- 6 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml create mode 100644 packages/app/maestro/sidebar-scroll-retention.yaml diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 4270ddb108..6bd528e3e8 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -32,8 +32,10 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. -The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while -React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists +The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while +React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing +their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or +release. Native panel hosts and their dependent draggable lists also retain identity across appearance hydration and settings changes. Do not wrap those hosts in appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences). diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 8ed61a8630..93fa35aedd 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in the headless input helper cannot verify the [visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). +The Android sidebar scroll-retention flow requires a connected host with enough +workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and +`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row +IDs. Choose one row near the top and one initially below the viewport. Replace +the quoted example values below with those IDs. Start in a workspace with the +sidebar closed and keep the list stable during the run. The same flow checks +reopening by swipe and button: + +```bash +agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \ + --maestro --platform android --serial emulator-5556 --metro-port 8099 \ + --env TOP_WORKSPACE='server-id:top-workspace-id' \ + --env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id' +``` + +Pass this checkout's Metro port so the debug app uses the intended bundle. +For iOS, compare the scrolled row's screen bounds and screenshots across reopening. +Agent Device's iOS visibility lookup includes offscreen scroll descendants, so +its `assertNotVisible` cannot establish the starting scroll position for this flow. + On a Play-image emulator, disable Gmail and Calendar first. Their welcome screens launch on their own, take input focus, and the run fails with `IME did not become visible` while the app still looks focused: diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml b/packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml new file mode 100644 index 0000000000..dd144b2926 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml @@ -0,0 +1,4 @@ +appId: sh.paseo.debug +--- +- launchApp +- runFlow: ../../../maestro/sidebar-scroll-retention.yaml diff --git a/packages/app/maestro/sidebar-scroll-retention.yaml b/packages/app/maestro/sidebar-scroll-retention.yaml new file mode 100644 index 0000000000..f7ded2e617 --- /dev/null +++ b/packages/app/maestro/sidebar-scroll-retention.yaml @@ -0,0 +1,53 @@ +appId: sh.paseo.debug +--- +# Start in a workspace with the sidebar closed. Both workspace IDs come from +# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts +# below the viewport. The list must remain unchanged throughout the flow. +- tapOn: + id: menu-button +- scrollUntilVisible: + element: + id: sidebar-workspace-row-${TOP_WORKSPACE} + direction: UP + timeout: 60000 +- assertNotVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- scrollUntilVisible: + element: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} + direction: DOWN + timeout: 60000 +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} +- tapOn: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- waitForAnimationToEnd +- assertNotVisible: + id: sidebar-project-workspace-list-scroll +- swipe: + start: "5%,50%" + end: "90%,50%" + duration: 300 +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} + +# Selecting the current workspace removes workspace switching as a cause. +# Reopening by button removes horizontal movement as a cause. +- tapOn: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- waitForAnimationToEnd +- tapOn: + id: menu-button +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} +- tapOn: + id: sidebar-close diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index b1d8a0036e..77059dfc39 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -42,7 +42,15 @@ export function MobilePanelOverlay({ const overlayAnimatedStyle = useAnimatedStyle(() => { const isVisible = isLeft ? position.value < 0 : position.value > 0; - return { display: isVisible ? ("flex" as const) : ("none" as const) }; + if (isWeb) { + return { display: isVisible ? ("flex" as const) : ("none" as const) }; + } + // Preserve native scroll ranges while hidden. Collapsing layout makes Android + // spring retained scroll offsets back to zero on touch cancellation or release. + return { + opacity: isVisible ? 1 : 0, + pointerEvents: isVisible ? ("box-none" as const) : ("none" as const), + }; }, [isLeft]); const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; @@ -79,10 +87,7 @@ export function MobilePanelOverlay({ pointerEvents={overlayPointerEvents} style={styles.overlay} > - + Date: Sun, 4 Oct 2026 14:12:33 +0000 Subject: [PATCH 031/281] fix(server): resume with last live mode, not creation-time mode (#5140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): resume with last live mode, not creation-time mode Provider-initiated mode switches (ACP current_mode_update, in-session commands like /bypass, permission-driven plan->acceptEdits transitions) update currentModeId/lastModeId but never reach config.modeId. On resume, buildConfigOverrides restored the stale creation-time mode and applyConfiguredOverrides pushed it back via set_mode — silently downgrading the session (e.g. bypass -> acceptEdits after restart). - buildConfigOverrides + buildStoredAgentConfig prefer lastModeId (the last observed live mode) over config.modeId - applyObservedMode syncs config.modeId on every provider-reported mode change (mode_changed, model_changed runtimeInfo, and the refreshSessionState poll) so in-memory reloadAgentSession carries the live mode as well Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(server): assert config.modeId sync on mode_changed/model_changed events Extends the config-drift stream test with a registry so both the in-memory agent.config.modeId and the persisted record.config.modeId are verified — covering the event paths Greptile flagged as untested. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 24 +++++++++++++++++-- .../server/src/server/agent/agent-manager.ts | 24 +++++++++++++++---- .../src/server/persistence-hooks.test.ts | 20 ++++++++++++++++ .../server/src/server/persistence-hooks.ts | 5 +++- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 1aafb98996..6396fc589f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5034,6 +5034,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous test("session config drift events update state through the stream channel", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); let capturedSession: TestAgentSession | null = null; class ConfigEventClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { @@ -5046,6 +5047,7 @@ test("session config drift events update state through the stream channel", asyn clients: { codex: new ConfigEventClient(), }, + registry: storage, logger, idFactory: () => "00000000-0000-4000-8000-000000000133", }); @@ -5100,6 +5102,7 @@ test("session config drift events update state through the stream channel", asyn const agent = manager.getAgent(snapshot.id); expect(agent?.currentModeId).toBe("build"); + expect(agent?.config.modeId).toBe("build"); expect(agent?.config.thinkingOptionId).toBe("high"); expect(agent?.availableModes).toEqual([ { id: "plan", label: "Plan" }, @@ -5111,6 +5114,10 @@ test("session config drift events update state through the stream channel", asyn thinkingOptionId: "high", }); expect(streams.map((event) => event.type)).toEqual([]); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastModeId).toBe("build"); + expect(persisted?.config?.modeId).toBe("build"); }); test("setLabels merges and persists labels", async () => { @@ -9287,6 +9294,7 @@ test("respondToPermission updates currentModeId after plan approval", async () = class PlanModeTestClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + resumedModeId: string | null | undefined; async isAvailable(): Promise { return true; @@ -9296,14 +9304,19 @@ test("respondToPermission updates currentModeId after plan approval", async () = return new PlanModeTestSession(); } - async resumeSession(): Promise { + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + this.resumedModeId = overrides?.modeId; return new PlanModeTestSession(); } } + const client = new PlanModeTestClient(); const manager = new AgentManager({ clients: { - codex: new PlanModeTestClient(), + codex: client, }, registry: storage, logger, @@ -9347,6 +9360,13 @@ test("respondToPermission updates currentModeId after plan approval", async () = await manager.flush(); const persisted = await storage.get(snapshot.id); expect(persisted?.lastModeId).toBe("acceptEdits"); + + // The provider-managed switch must also reach config.modeId — otherwise a + // reload resumes the session with the stale creation-time mode ("plan"). + expect(persisted?.config?.modeId).toBe("acceptEdits"); + const reloaded = await manager.reloadAgentSession(snapshot.id); + expect(reloaded.config.modeId).toBe("acceptEdits"); + expect(client.resumedModeId).toBe("acceptEdits"); }); test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 920f960e5c..9e809a457f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -190,10 +190,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { provider: record.provider, cwd: record.cwd, }; + // lastModeId is the last live mode — it also covers provider-side switches + // that never reach record.config.modeId. + const modeId = record.lastModeId ?? record.config?.modeId; + if (modeId != null) config.modeId = modeId; if (!record.config) { return config; } - if (record.config.modeId != null) config.modeId = record.config.modeId; if (record.config.model != null) config.model = record.config.model; if (record.config.thinkingOptionId != null) { config.thinkingOptionId = record.config.thinkingOptionId; @@ -3914,6 +3917,19 @@ export class AgentManager { return this.registry; } + /** + * Provider-side mode switches (ACP current_mode_update, in-session + * commands, permission-driven transitions) must land in config.modeId too — + * reloadAgentSession and the persisted record derive the resumed session's + * mode from it, so leaving it stale silently downgrades the mode on resume. + */ + private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void { + agent.currentModeId = modeId; + if (modeId != null) { + agent.config.modeId = modeId; + } + } + private async refreshSessionState( agent: ActiveManagedAgent, options?: { emit?: boolean }, @@ -3926,7 +3942,7 @@ export class AgentManager { } try { - agent.currentModeId = await agent.session.getCurrentMode(); + this.applyObservedMode(agent, await agent.session.getCurrentMode()); } catch { agent.currentModeId = null; } @@ -4306,7 +4322,7 @@ export class AgentManager { this.emitState(agent); return undefined; case "mode_changed": - agent.currentModeId = event.currentModeId; + this.applyObservedMode(agent, event.currentModeId); agent.availableModes = event.availableModes; if (agent.runtimeInfo) { agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId }; @@ -4322,7 +4338,7 @@ export class AgentManager { agent.cwd, ); } - agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId; + this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); flags.shouldDispatchEvent = false; this.emitState(agent); return undefined; diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index da8b5419a5..a0bbb49035 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -27,9 +27,28 @@ function createRecord(overrides?: Partial): StoredAgentRecord } describe("persistence hooks", () => { + test("buildConfigOverrides prefers the last live mode over the creation-time mode", () => { + const record = createRecord({ + lastModeId: "bypass", + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("bypass"); + }); + + test("buildConfigOverrides falls back to the configured mode when no live mode was recorded", () => { + const record = createRecord({ + lastModeId: null, + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("acceptEdits"); + }); + test("buildConfigOverrides preserves the complete private launch config", () => { const record = createRecord({ title: "Voice agent (current)", + lastModeId: "default", config: { modeId: "default", model: "gpt-5.4-mini", @@ -79,6 +98,7 @@ describe("persistence hooks", () => { const record = createRecord({ provider: "codex", title: "Renamed title", + lastModeId: null, config: { model: "gpt-5.4-mini", systemPrompt: "Confirm and speak first.", diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 45443a7af0..e95743d6be 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -67,7 +67,10 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial Date: Sun, 4 Oct 2026 17:16:14 +0200 Subject: [PATCH 032/281] fix(pi,omp): open sessions whose model was removed (#6061) * fix(pi,omp): open sessions whose model was removed Importing or reopening a Pi or OMP session failed with `Model "" not found` when the session's model was no longer configured, because the resume launch always passed `--model`. Resume now launches on the session's own model, which Pi and OMP replace with their default when it is gone, and switches to the requested model afterwards only when it differs and is available. Closes #6039 * fix(pi,omp): fall back only when the requested model is unavailable Check the provider's model list before switching on resume, so a failed switch to an available model reaches the caller instead of silently keeping the session's model. * fix(pi,omp): match a requested provider:id model on resume Compare the parsed provider and model id against the session and available models, so a reference written as provider:id switches instead of being treated as unavailable. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/omp/agent.test.ts | 47 +++++++++- .../src/server/agent/providers/omp/agent.ts | 45 +++++++-- .../providers/omp/test-utils/fake-omp.ts | 27 +++++- .../server/agent/providers/pi/agent.test.ts | 92 +++++++++++++++++-- .../src/server/agent/providers/pi/agent.ts | 38 +++++++- .../agent/providers/pi/test-utils/fake-pi.ts | 19 +++- 6 files changed, 246 insertions(+), 22 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 75034dc99e..640ead5d30 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -69,6 +69,51 @@ test("OMP import uses the runtime's custom agent directory without a configured expect.objectContaining({ providerHandleId: sessionFile }), ]); }); +test("OMP resumes a session whose model was removed on the model OMP falls back to", async () => { + const runtime = new FakeOmp(); + runtime.removeModel("9router/deepseek-v4-flash"); + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "fallback" } }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/fallback" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/fallback"); +}); + +test("OMP resumes a session on the requested model when it differs from the session's", async () => { + const runtime = new FakeOmp(); + const requestedModel = { provider: "openrouter", id: "requested" }; + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "recorded" } }; + session.models = [requestedModel]; + session.setModelResult = requestedModel; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "openrouter/requested" }, + }); + onTestFinished(() => session.close()); + + expect(runtime.latestSession().setModelRequests).toEqual([ + { provider: "openrouter", modelId: "requested" }, + ]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/requested" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/requested"); +}); + class ManualIdleScheduler implements OmpProviderIdleScheduler { private readonly retries: Array<() => void> = []; private readonly waiters: Array<{ count: number; resolve: () => void }> = []; diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index ce29ea6d29..a04b4c8fb0 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -445,7 +445,6 @@ function buildResumeStartInput(input: { protocolMode: "rpc-ui", env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizeOmpThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, ...(input.launchMode.modeId ? { modeId: input.launchMode.modeId } : {}), ...(input.launchMode.extraArgs ? { extraArgs: input.launchMode.extraArgs } : {}), @@ -2373,16 +2372,17 @@ export class OmpAgentClient implements AgentClient { resumeConfig.config, startInput.env, ); - const initialState = await this.restoreFastMode( - runtimeSession, - resumeConfig.config, - await runtimeSession.getState(), - ); + const resumedState = await this.applyResumeModel(runtimeSession, resumeConfig.model); + const config = { + ...resumeConfig.config, + model: modelToId(resumedState.model) ?? resumeConfig.config.model, + }; + const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ runtimeSession, hostTools, - restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext), - config: resumeConfig.config, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, initialState, currentModeId: launchMode.modeId, logger: this.logger, @@ -2400,6 +2400,35 @@ export class OmpAgentClient implements AgentClient { } } + // OMP resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: OmpRuntimeSession, + requestedModel: string | undefined, + ): Promise { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: OmpModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "OMP resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + private buildRestartRuntime( startInput: OmpStartSessionInput, config: AgentSessionConfig, diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index c54defed6f..403a389647 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -54,6 +54,8 @@ export class FakeOmp implements OmpRuntime { private readonly sessions: FakeOmpSession[] = []; private readonly command: [string, ...string[]]; private readonly queuedCommands: OmpRpcSlashCommand[][] = []; + private readonly queuedSessionSetups: Array<(session: FakeOmpSession) => void> = []; + private readonly removedModels = new Set(); private nextStartError: Error | null = null; private nextSessionId = 0; private readonly sessionIdsByFile = new Map(); @@ -75,16 +77,20 @@ export class FakeOmp implements OmpRuntime { session: input, }); this.recordedLaunches.push(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } const sessionId = (launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ?? `omp-session-${++this.nextSessionId}`; - const session = new FakeOmpSession(launch, sessionId); + const session = new FakeOmpSession(launch, sessionId, this.removedModels); if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId); session.commands = this.queuedCommands.shift() ?? []; for (const [level, error] of this.queuedSubagentSubscriptionErrors) { session.subagentSubscriptionErrors.set(level, error); } this.queuedSubagentSubscriptionErrors.clear(); + this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); return session; } @@ -93,6 +99,15 @@ export class FakeOmp implements OmpRuntime { this.queuedCommands.push(commands); } + queueSessionSetup(setup: (session: FakeOmpSession) => void): void { + this.queuedSessionSetups.push(setup); + } + + // OMP refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + failNextStart(error: Error): void { this.nextStartError = error; } @@ -174,7 +189,11 @@ export class FakeOmpSession implements OmpRuntimeSession { private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = null; - constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") { + constructor( + launch: OmpRuntimeLaunch, + sessionId = "omp-session-1", + private readonly removedModels: ReadonlySet = new Set(), + ) { this.environment = launch.env ?? {}; this.state = { model: null, @@ -333,9 +352,13 @@ export class FakeOmpSession implements OmpRuntimeSession { async setModel(provider: string, modelId: string): Promise { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakeOmp setModel requires setModelResult to be scripted"); } + this.state = { ...this.state, model: this.setModelResult }; return this.setModelResult; } diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index b87e0bace0..8035d59830 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -1528,8 +1528,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1539,6 +1537,87 @@ describe("PiRpcAgentSession", () => { ]); }); + test("resumes a session whose model was removed on the model Pi falls back to", async () => { + const pi = new FakePi(); + pi.removeModel("9router/deepseek-v4-flash"); + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + model: "kimi-coding/kimi-k3", + }); + expect(session.describePersistence()?.metadata?.model).toBe("kimi-coding/kimi-k3"); + }); + + test("resumes a session on the requested model when it differs from the session's", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }); + onTestFinished(() => session.close()); + + expect(pi.latestSession().setModelRequests).toEqual([{ provider: "openrouter", modelId: "a" }]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/a"); + }); + + test("resumes on a requested model written as provider:id", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter:a" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + }); + + test("fails the resume when switching to an available model fails", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + }); + + await expect( + createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }), + ).rejects.toThrow("FakePi setModel requires setModelResult to be scripted"); + }); + test("adopts Pi's clamped thinking level when resuming a session", async () => { const pi = new FakePi(); pi.queueSessionSetup((session) => { @@ -1715,8 +1794,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -2576,6 +2653,11 @@ describe("PiRpcAgentClient", () => { ); const pi = new FakePi(); pi.queueSessionSetup((session) => { + session.state.model = { + ...RESTRICTED_THINKING_MODEL, + provider: "openrouter", + id: "anthropic/claude-sonnet-4.5", + }; session.state.thinkingLevel = "high"; }); const client = new PiRpcAgentClient({ @@ -2594,8 +2676,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/anthropic/claude-sonnet-4.5", "--thinking", "high", "--session", diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 5a281ae0a6..5c9c7bb949 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -495,7 +495,6 @@ function buildResumeStartInput(input: { cwd: input.resumeConfig.cwd, env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, mcpConfigPath: input.mcpConfigFile?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, @@ -2590,10 +2589,14 @@ export class PiRpcAgentClient implements AgentClient { throw error; } try { + const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model); return new PiRpcAgentSession({ runtimeSession, - config: resumeConfig.config, - initialState: await runtimeSession.getState(), + config: { + ...resumeConfig.config, + model: modelToId(initialState.model) ?? resumeConfig.config.model, + }, + initialState, capabilities: capabilitiesForSession(mcp !== null), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: providerOptions.extensionTimeoutMs, @@ -2608,6 +2611,35 @@ export class PiRpcAgentClient implements AgentClient { } } + // Pi resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: PiRuntimeSession, + requestedModel: string | undefined, + ): Promise { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: PiModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "Pi resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 5b52b1eda4..b8c7930bc4 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -57,6 +57,7 @@ export class FakePi implements PiRuntime { private readonly command: [string, ...string[]]; private readonly queuedCommands: PiRpcSlashCommand[][] = []; private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = []; + private readonly removedModels = new Set(); constructor(command: [string, ...string[]] = ["pi"]) { this.command = command; @@ -68,7 +69,10 @@ export class FakePi implements PiRuntime { session: input, }); this.recordedLaunches.push(launch); - const session = new FakePiSession(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } + const session = new FakePiSession(launch, this.removedModels); session.commands = this.queuedCommands.shift() ?? []; this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); @@ -83,6 +87,11 @@ export class FakePi implements PiRuntime { this.queuedSessionSetups.push(setup); } + // Pi refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + latestSession(): FakePiSession { const session = this.sessions.at(-1); if (!session) { @@ -145,7 +154,10 @@ export class FakePiSession implements PiRuntimeSession { private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = null; - constructor(launch: PiRuntimeLaunch) { + constructor( + launch: PiRuntimeLaunch, + private readonly removedModels: ReadonlySet = new Set(), + ) { this.environment = launch.env ?? {}; this.state = { model: null, @@ -267,6 +279,9 @@ export class FakePiSession implements PiRuntimeSession { async setModel(provider: string, modelId: string): Promise { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakePi setModel requires setModelResult to be scripted"); } From 48329facc3f4752e2e6d6e4db648eb1761fd1784 Mon Sep 17 00:00:00 2001 From: Galaxy Roaming <45086632+apple-ouyang@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:06:41 +0800 Subject: [PATCH 033/281] fix(acp): surface context-window usage from the ACP usage_update notification (#4848) * fix(acp): show context-window usage for generic ACP agents Generic ACP providers never populated Paseo's context meter: the standard ACP `usage_update` notification was discarded by `handleUsageUpdate` (`void update`), while the meter only reads `lastUsage.contextWindowMaxTokens` / `contextWindowUsedTokens`, which `usage_updated` stream events write. Forward `size` and `used` on a `usage_updated` event for the active turn, guarded by the same validity rules the meter applies so a provider cannot push a window the meter rejects, and preserve the turn usage already recorded. Verified with vitest acp-agent.test.ts (106 passed), the server typecheck, and an isolated daemon run where a dsh agent reported `lastUsage={contextWindowMaxTokens:1000000,contextWindowUsedTokens:13623}`. * fix(acp): emit context-window usage only with both meter fields Require a valid size and used together so a partial usage_update cannot wipe the composer meter. Drive the tests through sessionUpdate instead of the private translator. --- .../server/agent/providers/acp-agent.test.ts | 40 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 23 ++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 9d106a2c1a..f847745d69 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -825,6 +825,46 @@ describe("mapACPUsage", () => { }); }); +describe("ACP context-window usage", () => { + async function emitUsageUpdate(update: { + used: number; + size: number; + }): Promise<{ events: unknown[] }> { + const session = createSessionWithConfig({ provider: "dsh" }); + asInternals(session).sessionId = "session-1"; + const events: unknown[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") events.push(event); + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { sessionUpdate: "usage_update", used: update.used, size: update.size }, + }); + return { events }; + } + + test("forwards usage_update as context-window usage state", async () => { + const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 }); + + expect(events).toEqual([ + { + type: "usage_updated", + provider: "dsh", + usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 }, + }, + ]); + }); + + test("emits nothing when size and used cannot both drive a meter", async () => { + await expect( + emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + await expect( + emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + }); +}); + describe("deriveModesFromACP", () => { test("prefers explicit ACP mode state", () => { const result = deriveModesFromACP( diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 524ede92dd..de8055c787 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -3178,8 +3178,29 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + // ACP reports context occupancy out of band from the prompt response, so the + // composer's context meter cannot read it from `turn_completed`. Forward it as + // the same `usage_updated` snapshot the native adapters emit, preserving the + // turn usage this session already recorded. The guards match the meter's own + // validity rules, so a provider cannot push a window the meter would reject. private handleUsageUpdate(update: UsageUpdate): void { - void update; + const contextWindowMaxTokens = + Number.isFinite(update.size) && update.size > 0 ? update.size : undefined; + const contextWindowUsedTokens = + Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined; + if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) { + return; + } + this.pushEvent({ + type: "usage_updated", + provider: this.provider, + usage: { + ...this.currentTurnUsage, + contextWindowMaxTokens, + contextWindowUsedTokens, + }, + turnId: this.activeForegroundTurnId ?? undefined, + }); } private handlePromptResponse(response: PromptResponse, turnId: string): void { From 5aee41952e15bb40c307fce3244740751fe95f00 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 17:12:11 +0100 Subject: [PATCH 034/281] feat(website): browse plugins by nine community categories (#6068) Organize community plugins by what Paseo does and verify the category layout with a static registry fixture. --- packages/website/e2e/plugins.spec.ts | 35 +- packages/website/e2e/registry-server.mjs | 14 + packages/website/e2e/registry.fixture.json | 631 ++++++++++++++++++ packages/website/playwright.config.ts | 19 +- packages/website/src/plugins/categories.ts | 62 +- packages/website/src/plugins/registry.test.ts | 2 +- 6 files changed, 722 insertions(+), 41 deletions(-) create mode 100644 packages/website/e2e/registry-server.mjs create mode 100644 packages/website/e2e/registry.fixture.json diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index bd8f70f004..e3b2eeb115 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "playwright/test"; +import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { // Wait for hydration so typing reaches React rather than the server-rendered input. @@ -27,7 +28,7 @@ test("browses from the directory into a category, a plugin, and its author", asy const themes = page.getByRole("region", { name: "Themes" }); await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible(); - await browseCategory(page, "Git & code review"); + await browseCategory(page, "Git"); await expect(page).toHaveURL(/category=/); await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1); await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0); @@ -48,7 +49,7 @@ test("browses from the directory into a category, a plugin, and its author", asy const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); await expect(breadcrumbs).toContainText("Plugins"); - await expect(breadcrumbs).toContainText("Git & code review"); + await expect(breadcrumbs).toContainText("Git"); await expect(breadcrumbs).toContainText("Fresh Worktrees"); await openAuthor(page, "Omer Cohen"); @@ -86,10 +87,7 @@ async function copyInstallCommand(page: Page) { } async function browseCategory(page: Page, category: string) { - await page - .getByRole("region", { name: category }) - .getByRole("link", { name: /^View all/ }) - .click(); + await page.getByRole("link", { name: category, exact: true }).click(); } async function openPlugin(page: Page, name: RegExp) { await page.getByRole("link", { name }).first().click(); @@ -214,3 +212,28 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ expect(response?.status()).toBe(200); await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); }); + +// External deployments own their registry contents; these assertions use the local fixture. +test.describe("registry category layout", () => { + test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + + test("orders populated categories and hides the empty Extras category", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 })).toHaveText([ + "Daemon management", + "Themes", + "Providers", + "Orchestration", + "Git", + "Workspaces", + "Sidebar", + "Utils", + ]); + await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0); + for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) { + await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText( + category.description, + ); + } + }); +}); diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs new file mode 100644 index 0000000000..211fee9f38 --- /dev/null +++ b/packages/website/e2e/registry-server.mjs @@ -0,0 +1,14 @@ +import { createServer } from "node:http"; +import registry from "./registry.fixture.json" with { type: "json" }; + +// Serve the published registry contract so SSR and browser navigation use the same data. +createServer((request, response) => { + response.setHeader("Content-Type", "application/json"); + if (request.url === "/index.json") { + response.end(JSON.stringify(registry)); + return; + } + const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + response.statusCode = plugin ? 200 : 404; + response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); +}).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json new file mode 100644 index 0000000000..6b75599d3d --- /dev/null +++ b/packages/website/e2e/registry.fixture.json @@ -0,0 +1,631 @@ +{ + "schemaVersion": 1, + "registry": { + "name": "Paseo plugins", + "url": "https://plugins.paseo.sh" + }, + "generatedAt": "2026-10-04T15:55:47.882Z", + "categories": [ + { + "slug": "daemon-management", + "label": "Daemon management", + "description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers." + }, + { + "slug": "themes", + "label": "Themes", + "description": "Change the app color scheme." + }, + { + "slug": "providers", + "label": "Providers", + "description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback." + }, + { + "slug": "orchestration", + "label": "Orchestration", + "description": "Coordinate agents, delegate work, and send messages across hosts." + }, + { + "slug": "git", + "label": "Git", + "description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews." + }, + { + "slug": "workspaces", + "label": "Workspaces", + "description": "Add workspace panels and tabs for boards, browsers, documents, and terminals." + }, + { + "slug": "sidebar", + "label": "Sidebar", + "description": "Add sidebar items and panels for usage, notifications, pricing, and links." + }, + { + "slug": "extras", + "label": "Extras", + "description": "Add presence, pets, puzzles, wellbeing reminders, and demos." + }, + { + "slug": "utils", + "label": "Utils", + "description": "Extend the composer and timeline with commands, prompts, skills, and small helpers." + } + ], + "plugins": [ + { + "id": "alhassanaraouf/base2tone", + "name": "Base2Tone", + "description": "Base2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md.", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/alhassanaraouf/paseo-themes/tree/HEAD/packages/paseo-base2tone-theme" + }, + "categories": ["themes"], + "author": { + "npm": "alhassanaraouf", + "name": "alhassanaraouf", + "github": "alhassanaraouf" + }, + "screenshots": [ + "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." + }, + { + "id": "dorasto/sayr", + "name": "Sayr", + "description": "A paseo plugin for viewing and managing your Sayr tasks", + "artifact": { + "kind": "npm", + "package": "@sayrio/paseo-plugin", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sayrio/paseo-plugin/-/paseo-plugin-0.2.0.tgz", + "integrity": "sha512-LPmVkEPWl8Km6US4zQAzhqSbGm7lBDwCjgNoysOhCsDiRy0jbtDKYRg1XVgwwUkoEwhF1EdZHCPwIuT8IizyWQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/dorasto/sayr/tree/HEAD/packages/paseo-plugin", + "commit": "e70919b89df3cf9e95c18242939193a79c7593b1" + }, + "categories": ["workspaces"], + "author": { + "npm": "trent-001", + "name": "Tom Lundy", + "github": "dorasto" + }, + "screenshots": [ + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T10:39:28.935Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" + }, + { + "id": "gpambrozio/github-board", + "name": "GitHub Board", + "description": "Paseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-github-board", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-github-board/-/paseo-github-board-0.10.0.tgz", + "integrity": "sha512-G6LxSsbt4Kt36yWEB2rXUxOwNLFsA4DbF20ViroVqaicMPjaw5H4JUWXjzEZAxWJT+0DlH12/TtqN4I0sNoQtg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/github-board", + "commit": "530a66c592e20915169b5e67a35de8793f71ec60" + }, + "categories": ["git"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-01T17:46:20.500Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" + }, + { + "id": "gpambrozio/herald", + "name": "Herald", + "description": "Paseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-herald", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-herald/-/paseo-herald-0.5.1.tgz", + "integrity": "sha512-rXdlaxgRlYP3nmZrvbG29sQeluz5JC7TPuJDgKzyh9itDH1AvZYGjLgGp9XZGKxdBBhaGxgGAFhDh+VTL8h9dw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/herald", + "commit": "b658d0ab58735959e46d12660ae179c002cdadda" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-27T19:03:31.943Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" + }, + { + "id": "gpambrozio/launchd-jobs", + "name": "launchd Jobs", + "description": "Paseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-launchd-jobs", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-launchd-jobs/-/paseo-launchd-jobs-0.5.0.tgz", + "integrity": "sha512-H6CQnalMdyQjW9ta9ZT40qEPJsREgNFSEAH8EC4sJXdz2Hz9tIsInSUmm2bDcgfY3D3HFffUrwf1mmeevi/wUA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/launchd-jobs", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["daemon-management"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T17:56:41.061Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" + }, + { + "id": "gpambrozio/model-pricing", + "name": "Model Pricing", + "description": "Paseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-model-pricing", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-model-pricing/-/paseo-model-pricing-0.3.0.tgz", + "integrity": "sha512-jLfC6iCLOwNACiili8nw3/qmBymoj6YHitMeGCa2RggiMdUhT6n+INRsSjoTs2kNzeYMftMfQ3MZJXeLLF1opA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/model-pricing", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T18:03:14.979Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" + }, + { + "id": "gpambrozio/skills", + "name": "Skills", + "description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-skills", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-skills/-/paseo-skills-0.4.0.tgz", + "integrity": "sha512-zMgP2U/vrRsLkKhoQICkN52+YoDy2WkkYymEDmruduKxf4Dq99Ct8Pk3xqJyAzijq2oprMFYLbj/wT6XepBLeA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/skills", + "commit": "d04187ee60ca3702ca5843803567600427102d99" + }, + "categories": ["utils"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-01T22:25:58.192Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" + }, + { + "id": "hungcuong9125/prompt-kit", + "name": "PromptKit", + "description": "PromptKit - rewrite and transform prompts directly from the Paseo composer.", + "artifact": { + "kind": "npm", + "package": "paseo-prompt-kit", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/paseo-prompt-kit/-/paseo-prompt-kit-0.6.0.tgz", + "integrity": "sha512-eiagOY1OokDx2iihfe3Vjh2dgKKP6IsBKbJYbfhM+TXw3aU7duV/rs/TUnBSeeWAk0cEsVg2Jd+EkeaPM8IJdA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/hungcuong9125/paseo-prompt-kit" + }, + "categories": ["utils"], + "author": { + "npm": "hungcuong9125", + "name": "hungcuong9125", + "github": "hungcuong9125" + }, + "screenshots": [ + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-30T02:57:56.473Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." + }, + { + "id": "omercnet/agent-monitor", + "name": "Agent Monitor", + "description": "A Paseo plugin that provides a host-wide agent triage roster.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-agent-monitor", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-agent-monitor/-/paseo-agent-monitor-1.2.0.tgz", + "integrity": "sha512-dY7/3f49sPMVwb+Wr7iUsw4jCoQWRX2UF9Li+uU8yJdN0cj4BvTsz4GDiR3OsKk8zJ1/o01DSRi1e4uDRS3TmA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/agent-monitor", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:35.639Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." + }, + { + "id": "omercnet/beads", + "name": "Beads", + "description": "A dependency-aware Beads work queue for each Paseo workspace.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-beads", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-beads/-/paseo-beads-1.2.0.tgz", + "integrity": "sha512-UkI1SNP73RUrW0ap4qOx2Jv1GCxkrw4HIMrWOFCS3FDoPg2jw06DqHC8Jf34psaFglxXKWLAOkfL2wEZJhlUFw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-beads", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:29.149Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." + }, + { + "id": "omercnet/dracula", + "name": "Dracula", + "description": "Dracula Classic and Alucard Classic themes for Paseo.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-dracula", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-dracula/-/paseo-dracula-1.2.0.tgz", + "integrity": "sha512-I3yUiFIvaIe3jdvuQ1tZyngsfgukaUJwEQza2XYkEaB8yIA0pplZehqJLvqqmA403cjNXdVtsdW7umPtmnycqw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-dracula", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["themes"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:18.185Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." + }, + { + "id": "omercnet/fresh-worktrees", + "name": "Fresh Worktrees", + "description": "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-fresh-worktrees", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-fresh-worktrees/-/paseo-fresh-worktrees-2.2.0.tgz", + "integrity": "sha512-WRarzdfTbrZ36JfzmvX9HfnIYh6Pp+GV9TkoeVwM7I6f1MAEZ4gzPEmT51lWuUyMGvwTs2HFB7LthCa+VYs7IA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/fresh-worktrees", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:06.720Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" + }, + { + "id": "omercnet/omp", + "name": "OMP", + "description": "Paseo integration for OMP, including its direct provider and workspace tooling.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-omp", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-omp/-/paseo-omp-1.2.0.tgz", + "integrity": "sha512-ueZede5D34hoNrdF4y6DBaomErA+hHcv+bcz0Sd6eArQKqF8L6ecKXdBe2tEZd3Ev8uQwxMHuiDQhMBx3q6gOw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-omp", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["providers"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:36.267Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." + }, + { + "id": "omercnet/pr-radar", + "name": "PR Radar", + "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-pr-radar", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-pr-radar/-/paseo-pr-radar-1.2.0.tgz", + "integrity": "sha512-sk+zuzTGZ5Vmri2toic3z3uChm4d6kEShSr/UjkqfOTeILrsZVDHUc+XKX/CtZIQazT/vWvbgZ115+KUiAonCw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/pr-radar", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:04.327Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." + }, + { + "id": "omercnet/shared-browser", + "name": "Shared Browser", + "description": "One real Chromium session per Paseo workspace, shared live across every connected client.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-shared-browser", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-shared-browser/-/paseo-shared-browser-1.2.0.tgz", + "integrity": "sha512-ddvtkBSzHLFzyTRZFixO1y+QXOKW0of8Uo3PSkETRhW5xhd9ep0jqsJJF9JSfWLt1ouvVa5K8zFXic/EBzQ8lw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-shared-browser", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:38:48.289Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." + }, + { + "id": "omercnet/tell-agent", + "name": "Tell Agent", + "description": "Send messages to agents in other workspaces on the same Paseo host.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-tell-agent", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-tell-agent/-/paseo-tell-agent-1.2.0.tgz", + "integrity": "sha512-4XqL1RK1x3TQLoRRKTkZfDxytJbUCEnu2em5pbPDpsGQ5Gv6VN1h07mBci9iW8s2FbNINohNcIBdUaAMTXdOHA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/tell-agent", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:15.719Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." + }, + { + "id": "tomgrin10/defer", + "name": "Defer", + "description": "Defer messages to Paseo coding agents until a chosen time or usage-window reset.", + "artifact": { + "kind": "npm", + "package": "paseo-defer", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/paseo-defer/-/paseo-defer-2.1.9.tgz", + "integrity": "sha512-xBBf4TUC8zkTh9WMzQRRDt0alRAYPRCmUdBnBI1ZEkatUFC4i9MxFHgaTY939xij99dASM/261IWqctcKsQppQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-defer" + }, + "categories": ["utils"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "screenshots": [ + "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-27T09:59:16.553Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." + }, + { + "id": "tomgrin10/graphite", + "name": "Graphite", + "description": "Workspace-native Graphite stack status and PR action radar for Paseo.", + "artifact": { + "kind": "npm", + "package": "paseo-graphite", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/paseo-graphite/-/paseo-graphite-0.2.4.tgz", + "integrity": "sha512-aAFvh+118c5mmSQ3sTsDKO8Is7g1ZU8m2+0fisr8nFrgPMu0m2mokPJnM/LaIqQc3Zaaak1chB6M6k0dWOPeIA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-graphite" + }, + "categories": ["git"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "screenshots": [], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-23T10:36:50.112Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." + } + ] +} diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index 28566f30b4..a80b84abd8 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -12,9 +12,18 @@ export default defineConfig({ ], webServer: process.env.WEBSITE_TEST_URL ? undefined - : { - command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", - url: baseURL, - reuseExistingServer: !process.env.CI, - }, + : [ + { + command: "node e2e/registry-server.mjs", + url: "http://127.0.0.1:8188/index.json", + }, + { + command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", + url: baseURL, + env: { + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", + PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", + }, + }, + ], }); diff --git a/packages/website/src/plugins/categories.ts b/packages/website/src/plugins/categories.ts index 498bea280e..96fc836bf4 100644 --- a/packages/website/src/plugins/categories.ts +++ b/packages/website/src/plugins/categories.ts @@ -1,47 +1,51 @@ export const CATEGORIES = [ - { slug: "themes", label: "Themes", description: "Change how Paseo looks." }, { - slug: "agents-and-providers", - label: "Agents & providers", - description: "Run more coding agents through Paseo.", + slug: "daemon-management", + label: "Daemon management", + description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.", }, { - slug: "monitoring", - label: "Monitoring & orchestration", - description: "See every agent at once and steer work across workspaces.", + slug: "themes", + label: "Themes", + description: "Change the app color scheme.", }, { - slug: "timeline-and-composer", - label: "Timeline & composer", - description: "Change what a conversation shows and how you write to agents.", + slug: "providers", + label: "Providers", + description: + "Connect coding agents, ACP servers, and model routers, with account and quota fallback.", }, { - slug: "workspace-panels", - label: "Workspace panels", - description: "Tools that open as tabs next to your agents.", + slug: "orchestration", + label: "Orchestration", + description: "Coordinate agents, delegate work, and send messages across hosts.", }, { - slug: "git-and-code-review", - label: "Git & code review", - description: "Branches, worktrees, and pull requests.", + slug: "git", + label: "Git", + description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.", }, { - slug: "automation", - label: "Automation & scheduling", - description: "Run things later, on a schedule, or when an agent goes idle.", - }, - { slug: "notifications", label: "Notifications", description: "Know when an agent needs you." }, - { - slug: "usage-and-pricing", - label: "Usage & pricing", - description: "What models cost and how much you have used.", + slug: "workspaces", + label: "Workspaces", + description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.", }, { - slug: "integrations", - label: "Integrations", - description: "Issues, tasks, and tools from other services inside Paseo.", + slug: "sidebar", + label: "Sidebar", + description: "Add sidebar items and panels for usage, notifications, pricing, and links.", + }, + { + slug: "extras", + label: "Extras", + description: "Add presence, pets, puzzles, wellbeing reminders, and demos.", + }, + { + slug: "utils", + label: "Utils", + description: + "Extend the composer and timeline with commands, prompts, skills, and small helpers.", }, - { slug: "utilities", label: "Utilities", description: "Small helpers for everyday work." }, ] as const; export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index 20f5cfda6e..ed5963fb0a 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -63,7 +63,7 @@ describe("plugin registry", () => { "acme/example", ]); expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]); - expect(queryPlugins([plugin], { category: "utilities", sort: "popular" })).toEqual([]); + expect(queryPlugins([plugin], { category: "utils", sort: "popular" })).toEqual([]); }); it("offers a registry command and an exact explicit command", () => { expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); From 642d69b142c3142bc438cb1cc75ee6a4f1a24fce Mon Sep 17 00:00:00 2001 From: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:00:31 +0900 Subject: [PATCH 035/281] fix(app): cache file links with their line locations (#5653) --- packages/app/e2e/browser/file-editing.spec.ts | 30 +++++++++++++++++++ .../src/assistant-file-links/use-file-link.ts | 13 +++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 3c7475627d..43e1e03863 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -96,6 +96,36 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("jumps to each relative reference to the same file", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "relative-reference-qa-", + title: "Relative line references", + initialPrompt: "Show both references", + featureValues: { + mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.", + }, + }); + try { + await mkdir(path.join(session.cwd, "src")); + await writeFile( + path.join(session.cwd, "src/router.go"), + Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"), + ); + await openAgentRoute(page, session); + for (const line of [46, 403, 46]) { + await page + .getByTestId(`workspace-tab-agent_${session.agentId}`) + .filter({ visible: true }) + .click(); + await page.getByText(`src/router.go:${line}`, { exact: true }).click(); + await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible(); + } + await page.screenshot({ path: test.info().outputPath("relative-line-target.png") }); + } finally { + await session.cleanup(); + } + }); + test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({ page, }) => { diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 2a08594aa5..194405d2ec 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult ? assistantFileLinkQueryKey({ serverId, workspaceRoot, - ambiguousQuery: resolution.ambiguousQuery, + token: resolution.token, }) : DISABLED_QUERY_KEY, [resolution, serverId, workspaceRoot], @@ -179,7 +179,7 @@ function openAssistantFileLink(input: { const capturedQueryKey = assistantFileLinkQueryKey({ serverId: capturedConfig.serverId, workspaceRoot: capturedConfig.workspaceRoot, - ambiguousQuery: capturedResolution.ambiguousQuery, + token: capturedResolution.token, }); const run = async () => { @@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour function assistantFileLinkQueryKey(input: { serverId?: string; workspaceRoot?: string; - ambiguousQuery: string; + token: string; }): AssistantFileLinkQueryKey { - return [ - "assistantFileLink", - input.serverId ?? null, - input.workspaceRoot ?? null, - input.ambiguousQuery, - ]; + return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token]; } async function dispatchResolvedLink(input: { From febe9611bf3bb1a03309ffd2ce6ac90f913913f9 Mon Sep 17 00:00:00 2001 From: Phieu Dinh <42538587+dinhphieu@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:04:30 +0700 Subject: [PATCH 036/281] fix(app): stop composer autofocus on touch screens (#5477) A tablet or an unfolded foldable is wide enough for the desktop layout, so opening an agent autofocused the composer and raised the on-screen keyboard over the conversation. Autofocus on open now also requires a fine pointer, the same signal the app already uses for hover. A mouse-driven window still focuses the composer when an agent opens, and sending a message still keeps the composer focused. Co-authored-by: Phieu Dinh --- .../app/e2e/browser/composer-focus.spec.ts | 51 ++++++++++++++++++- packages/app/e2e/support/helpers/composer.ts | 4 ++ packages/app/src/composer/index.tsx | 5 +- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 897e536340..b79cfd9255 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,7 +1,8 @@ -import { test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { expectComposerDraft, expectComposerFocused, + expectComposerNotFocused, expectComposerVisible, submitMessage, typeIntoFocusedComposer, @@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn await agent.cleanup(); } }); + +test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-open-", + title: "Composer focus on open", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerFocused(page); + } finally { + await agent.cleanup(); + } +}); + +async function waitForAnimationFrames(page: Page, count: number): Promise { + await page.evaluate(async (frames) => { + for (let frame = 0; frame < frames; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }, count); +} + +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("opening an agent leaves the composer unfocused", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-touch-", + title: "Composer focus on touch", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + expect( + await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches), + ).toBe(false); + // Autofocus makes its first attempt on the next animation frame. + await waitForAnimationFrames(page, 4); + await expectComposerNotFocused(page); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 6e78e0c66b..ab53705477 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise { await expect(composerInput(page)).toBeFocused(); } +export async function expectComposerNotFocused(page: Page): Promise { + await expect(composerInput(page)).not.toBeFocused(); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 57b9e3fb44..007051fe1c 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -27,6 +27,7 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -1312,6 +1313,7 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); + const hasFinePointer = useHasFinePointer(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const hasText = useSyncExternalStore( @@ -2383,7 +2385,8 @@ function ComposerContentImpl({ { disabled: isSubmitLoadingVisible }, ); - const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; + // Focusing the composer on a touch screen raises the on-screen keyboard over the conversation. + const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( () => From c062280429c2aa894d98ce8d5c8a270c519085c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 20:38:39 +0100 Subject: [PATCH 037/281] Ship the plugin directory redesign and keep registry installs behind a daemon flag (#6080) * website: plugin page design mockups (four directions, throwaway routes) * website: final plugin page mockup (A directory, B browse frame, list cards, D detail) * website: browse pages use a four-column card grid in the final plugin mockup * feat(plugins): keep registry installs behind a daemon flag, off by default Off restores the pre-registry behavior: bare owner/repo is GitHub shorthand and the daemon never resolves or contacts a registry on install, update preview, or update. Enable with pluginRegistryEnabled: true or PASEO_PLUGIN_REGISTRY_ENABLED=1. * feat(website): ship the plugin directory, browse pages, and single-column detail Directory: count, build/submit links, What's new, the nine categories, and Most installed with a This week / This month / All time switch. Browse pages at /plugins/all and /plugins/category/ with a category sidebar, tabs, and a four-column grid; old /plugins?category= links redirect. Install counts gain daily KV buckets kept 60 days. * fix(website): keep a README's leading GitHub alert on the plugin page * feat(website): search plugins from the directory and browse pages Typing replaces the URL with /plugins/all?q= (or the current category with ?q=), which renders matching plugins on the server. Matching covers name, description, ID, and author. Old /plugins?q= links redirect. * fix(website): keep the directory's ranking window when searching --- docs/plugins.md | 15 +- packages/cli/src/commands/plugin/index.ts | 2 +- packages/server/src/server/bootstrap.ts | 2 + .../server/src/server/config-environment.ts | 1 + packages/server/src/server/config.test.ts | 9 + packages/server/src/server/config.ts | 4 + .../server/src/server/persisted-config.ts | 1 + .../plugins/managed-source.posix.test.ts | 69 +++- .../src/server/plugins/managed-source.ts | 18 +- .../server/plugins/managed-source/registry.ts | 2 + packages/website/e2e/plugins.spec.ts | 213 ++++++---- .../website/src/components/site-footer.tsx | 11 +- .../website/src/components/site-shell.tsx | 11 +- packages/website/src/plugins/browse-page.tsx | 214 ++++++++++ .../website/src/plugins/category-link.tsx | 36 -- .../website/src/plugins/contribute-links.tsx | 17 + packages/website/src/plugins/installs.ts | 73 ++++ packages/website/src/plugins/links.ts | 48 +++ packages/website/src/plugins/loader.ts | 19 +- packages/website/src/plugins/plugin-card.tsx | 137 +++++-- .../src/plugins/plugin-page-header.tsx | 10 - .../website/src/plugins/plugin-search.tsx | 53 +++ packages/website/src/plugins/published.ts | 47 ++- packages/website/src/plugins/registry.test.ts | 98 ++++- packages/website/src/plugins/registry.ts | 75 ++-- .../website/src/plugins/window-switch.tsx | 35 ++ packages/website/src/routeTree.gen.ts | 48 ++- .../website/src/routes/plugins/$owner.tsx | 14 +- .../src/routes/plugins/$owner_.$slug.tsx | 237 +++-------- packages/website/src/routes/plugins/all.tsx | 43 ++ .../src/routes/plugins/category/$slug.tsx | 52 +++ packages/website/src/routes/plugins/index.tsx | 383 ++++++++---------- public-docs/plugins/reference.md | 19 +- 33 files changed, 1335 insertions(+), 681 deletions(-) create mode 100644 packages/website/src/plugins/browse-page.tsx delete mode 100644 packages/website/src/plugins/category-link.tsx create mode 100644 packages/website/src/plugins/contribute-links.tsx create mode 100644 packages/website/src/plugins/installs.ts create mode 100644 packages/website/src/plugins/links.ts delete mode 100644 packages/website/src/plugins/plugin-page-header.tsx create mode 100644 packages/website/src/plugins/plugin-search.tsx create mode 100644 packages/website/src/plugins/window-switch.tsx create mode 100644 packages/website/src/routes/plugins/all.tsx create mode 100644 packages/website/src/routes/plugins/category/$slug.tsx diff --git a/docs/plugins.md b/docs/plugins.md index 1093381c61..510ef68af1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -117,7 +117,12 @@ the bundled integration; an entry with `extends` shadows it with a custom provid ## Install from a registry -`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs +Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or +`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare +`owner/repo` is GitHub shorthand and the daemon never contacts a registry. + +With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed +artifact, and GitHub shorthand requires `github:`. Registry installs keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref selection is unavailable for registry installs; install an explicit source to select your own. @@ -133,13 +138,13 @@ owns static hosting, record shapes, pins, and advisory install counts. ## Install a Git source -GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory -still wins over source resolution. +GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git +URL. An existing directory still wins over source resolution. ```bash -paseo plugin install github:owner/repository +paseo plugin install owner/repository paseo plugin install https://gitlab.com/group/repository.git -paseo plugin install github:owner/monorepo:plugins/review +paseo plugin install owner/monorepo:plugins/review paseo plugin install github:owner/repository --ref main ``` diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 4250ba8620..993c690f68 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -218,7 +218,7 @@ export function createPluginCommand(): Command { .description("Trust and install a plugin from a directory, Git repository, or npm package") .argument( "", - "Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source", + "Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path", ) .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3f49f41a19..e63ce89fb3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -414,6 +414,7 @@ export interface PaseoDaemonConfig { plugins?: Record; pluginRegistries?: PluginRegistries; pluginRegistryUrl?: string; + pluginRegistryEnabled?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -624,6 +625,7 @@ export async function createPaseoDaemon( usageSession: (id) => agentManager.usageSession(id), }, managedSources: new ManagedPluginSources(config.paseoHome, { + enabled: config.pluginRegistryEnabled ?? false, registries: config.pluginRegistries, defaultUrl: config.pluginRegistryUrl, }), diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 24134035de..2442ee5dc5 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -30,6 +30,7 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", "PASEO_PLUGIN_REGISTRY", + "PASEO_PLUGIN_REGISTRY_ENABLED", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index a71b5621e8..02a315c9a5 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -211,6 +211,15 @@ test("loads private plugin registry settings through the configuration boundary" }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); + expect(config.pluginRegistryEnabled).toBe(false); + await writeFile( + path.join(home, "config.json"), + JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }), + ); + expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true); + expect( + loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled, + ).toBe(false); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7b2d06560e..61481ce1f9 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -628,6 +628,10 @@ export function resolveConfigFromPersisted( plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, + pluginRegistryEnabled: + parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ?? + persisted.pluginRegistryEnabled ?? + false, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 79e3d97218..f924a88ebc 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -308,6 +308,7 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), + pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 7723721eb4..40878ae74e 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -36,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise { return stdout.trim(); } +/** Points https://github.com/fixture/repository.git at a local repository for the callback. */ +async function withGitHubFixture(repository: string, run: () => Promise): Promise { + const overlay = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`, + GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git", + }; + const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]])); + Object.assign(process.env, overlay); + try { + await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + describe("managed Git plugin sources", () => { it("does not expose Git URL credentials when cloning fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-")); @@ -52,14 +71,7 @@ describe("managed Git plugin sources", () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); roots.push(home); - const overlay = { - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`, - GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git", - }; - const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]])); - Object.assign(process.env, overlay); - try { + await withGitHubFixture(repository, async () => { const sources = new ManagedPluginSources(home); for (const source of ["github:fixture/repository", "git:fixture/repository"]) { const candidate = await sources.prepareInstall({ source }); @@ -69,12 +81,7 @@ describe("managed Git plugin sources", () => { }); await sources.discard(candidate); } - } finally { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } + }); }, 30_000); it("offers current default HEAD after installing a tag", async () => { @@ -273,6 +280,37 @@ describe("managed Git plugin sources", () => { }); describe("registry plugin sources", () => { + it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => { + const repository = await createRepository(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-")); + roots.push(home); + const requests: Array = []; + const server = createServer((req, res) => { + requests.push(req.url); + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + await withGitHubFixture(repository, async () => { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + const candidate = await sources.prepareInstall({ source: "fixture/repository" }); + expect(candidate.record).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + await sources.discard(candidate); + }); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30_000); + it("installs a bare id from a private registry and polls its pin without install intent", async () => { const repository = await createRepository(); await mkdir(path.join(repository, "packages/example"), { recursive: true }); @@ -331,6 +369,7 @@ describe("registry plugin sources", () => { const url = `http://${host}/internal`; try { const sources = new ManagedPluginSources(home, { + enabled: true, defaultUrl: url, registries: { [host]: { authorization: "Bearer test" } }, }); @@ -405,7 +444,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing registry address"); - const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; + const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` }; try { const sources = new ManagedPluginSources(home, options); const candidate = await sources.place( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 366d8d53b0..8ee3b2b6f6 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,7 +147,9 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { - const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + const registry = this.registryOptions.enabled + ? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl) + : null; if (registry) { if (input.ref || input.pluginPath) throw new Error("Registry sources use their reviewed artifact path and revision"); @@ -173,7 +175,7 @@ export class ManagedPluginSources { sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName); record = { kind: "npm" }; } else { - const remote = normalizeGitSource(input.source); + const remote = normalizeGitSource(input.source, !this.registryOptions.enabled); sourceRoot = path.join(versionRoot, "checkout"); await clone(remote, sourceRoot); const commit = await resolveRequestedRef(sourceRoot, input.ref); @@ -225,7 +227,7 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.registry) { + if (this.registryOptions.enabled && current.identity.registry) { if (selection) throw new Error("Registry updates use only the reviewed registry pin"); const resolved = await resolveRegistryPlugin( current.identity.registry, @@ -302,7 +304,7 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; - if (identity.kind !== "directory" && identity.registry) { + if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) { const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); assertRegistryArtifactIdentity(identity, resolved.artifact); if (!isDeepStrictEqual(resolved.target, proposal.target)) @@ -434,8 +436,10 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } -function normalizeGitSource(source: string): string { - const explicit = source.startsWith("github:") || source.startsWith("git:"); +/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */ +function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string { + const expandsShorthand = + bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { @@ -446,7 +450,7 @@ function normalizeGitSource(source: string): string { source = source.slice(4); } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github && explicit) { + if (github && expandsShorthand) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index d57d0aea79..179bdc5da7 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -6,6 +6,8 @@ import { import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; export interface RegistryOptions { + /** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */ + enabled?: boolean; defaultUrl?: string; registries?: PluginRegistries; } diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index e3b2eeb115..d2066bca61 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -2,9 +2,8 @@ import { expect, test, type Page } from "playwright/test"; import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { - // Wait for hydration so typing reaches React rather than the server-rendered input. - await page.goto("/plugins", { waitUntil: "networkidle" }); - await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); + await page.goto("/plugins"); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } test("browses from the directory into a category, a plugin, and its author", async ({ @@ -25,88 +24,122 @@ test("browses from the directory into a category, a plugin, and its author", asy }); await openPlugins(page); - const themes = page.getByRole("region", { name: "Themes" }); - await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible(); + await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); - await browseCategory(page, "Git"); - await expect(page).toHaveURL(/category=/); - await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1); - await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0); - - await openPlugin(page, /Fresh Worktrees/); + await page.getByRole("link", { name: /Fresh Worktrees/ }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); - await expect( - page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }), - ).toBeVisible(); - await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); + await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); await expect( page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), ).toBeVisible(); - await copyInstallCommand(page); + await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) .toBe("paseo plugin install omercnet/fresh-worktrees"); + await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute( + "href", + "/plugins/category/git", + ); - const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); - await expect(breadcrumbs).toContainText("Plugins"); - await expect(breadcrumbs).toContainText("Git"); - await expect(breadcrumbs).toContainText("Fresh Worktrees"); - - await openAuthor(page, "Omer Cohen"); + await page.getByRole("link", { name: "Omer Cohen" }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet$/); await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); }); -test("filters by search and clears to the full directory", async ({ page }) => { +test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => { + await page.goto("/plugins/all"); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute( + "aria-current", + "page", + ); + await page.getByRole("link", { name: "This month" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?window=month$/); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); + + await page.getByRole("link", { name: "Newest" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=new$/); + await expect(page.getByRole("navigation", { name: "Time window" })).toHaveCount(0); + await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText( + "ago", + ); +}); + +test("filters by search and clears to all plugins", async ({ page }) => { + await page.goto("/"); await openPlugins(page); await searchPlugins(page, "graphite"); - await expect(page).toHaveURL(/q=graphite/); - await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible(); - await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); await searchPlugins(page, "zzzz-nothing"); await expect(page.getByText("No plugins match.")).toBeVisible(); await page.getByRole("link", { name: "Clear filters" }).click(); - await expect(page).toHaveURL(/\/plugins\/?$/); - await expect(page.getByRole("region", { name: "Themes" })).toBeVisible(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); +}); + +test("keeps the directory's ranking window when searching", async ({ page }) => { + await page.goto("/plugins?window=month"); + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); +}); + +test("replaces history while typing a search", async ({ page }) => { + await page.goto("/"); + await page.goto("/plugins/all"); + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await page.goBack(); + await expect(page).toHaveURL(/\/$/); +}); + +test("keeps old category links working", async ({ page }) => { + await page.goto("/plugins?category=git&sort=new"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?sort=new$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); }); test("explains a plugin that is not listed", async ({ page }) => { await page.goto("/plugins/acme/does-not-exist"); await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); await page.getByRole("link", { name: "Browse all plugins" }).click(); - await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); }); -async function copyInstallCommand(page: Page) { - await page.getByRole("button", { name: "Copy to clipboard" }).first().click(); -} - -async function browseCategory(page: Page, category: string) { - await page.getByRole("link", { name: category, exact: true }).click(); -} -async function openPlugin(page: Page, name: RegExp) { - await page.getByRole("link", { name }).first().click(); -} -async function openAuthor(page: Page, name: string) { - await page.getByRole("link", { name }).first().click(); -} -async function searchPlugins(page: Page, query: string) { - await page.getByRole("searchbox", { name: "Search plugins" }).fill(query); -} - test.describe("search engine visits without JavaScript", () => { test.use({ javaScriptEnabled: false }); - test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => { + test("reads directory, category, author, and complete plugin HTML with page metadata", async ({ + page, + }) => { await openPlugins(page); - await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: /Base2Tone/ }).first()).toBeVisible(); await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + await page.goto("/plugins/category/git"); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await expectPageMetadata(page, "Git – Paseo plugins", "/plugins/category/git"); + await page.goto("/plugins/omercnet"); await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); @@ -143,7 +176,27 @@ test.describe("search engine visits without JavaScript", () => { ); }); - test("returns real 404 pages for unknown plugins and authors", async ({ page }) => { + test("renders search results and submits the search form", async ({ page }) => { + await page.goto("/plugins/all?q=graphite"); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await page.goto("/plugins/category/git"); + await searchPlugins(page, "fresh"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?q=fresh$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “fresh” in Git/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await page.getByRole("link", { name: "Clear", exact: true }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + }); + + test("returns real 404 pages for unknown plugins, authors, and categories", async ({ page }) => { const plugin = await page.goto("/plugins/acme/does-not-exist"); expect(plugin?.status()).toBe(404); await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); @@ -152,9 +205,24 @@ test.describe("search engine visits without JavaScript", () => { expect(author?.status()).toBe(404); await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); await expect(page).toHaveTitle("Author not found – Paseo"); + const category = await page.goto("/plugins/category/does-not-exist"); + expect(category?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Category not found" })).toBeVisible(); + await expect(page).toHaveTitle("Category not found – Paseo"); }); - test("discovers plugin and author URLs through robots and the sitemap index", async ({ + test("redirects old category links permanently", async ({ request }) => { + const response = await request.get("/plugins?category=git", { maxRedirects: 0 }); + expect(response.status()).toBe(301); + expect(response.headers().location).toMatch(/\/plugins\/category\/git$/); + const search = await request.get("/plugins?q=graphite", { maxRedirects: 0 }); + expect(search.status()).toBe(301); + expect(search.headers().location).toMatch(/\/plugins\/all\?q=graphite$/); + const windowed = await request.get("/plugins?q=graphite&window=month", { maxRedirects: 0 }); + expect(windowed.headers().location).toMatch(/\/plugins\/all\?q=graphite&window=month$/); + }); + + test("discovers plugin, category, and author URLs through robots and the sitemap index", async ({ request, }) => { const robots = await request.get("/robots.txt"); @@ -164,13 +232,18 @@ test.describe("search engine visits without JavaScript", () => { const plugins = await request.get("/sitemap-plugins.xml"); expect(plugins.status()).toBe(200); expect(plugins.headers()["content-type"]).toContain("application/xml"); - expect(await plugins.text()).toContain("https://paseo.sh/plugins/omercnet"); - expect(await plugins.text()).toContain( - "https://paseo.sh/plugins/omercnet/fresh-worktrees", - ); + const sitemap = await plugins.text(); + expect(sitemap).toContain("https://paseo.sh/plugins/all"); + expect(sitemap).toContain("https://paseo.sh/plugins/category/git"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet/fresh-worktrees"); }); }); +async function searchPlugins(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); +} + async function expectPageMetadata(page: Page, title: string, path: string) { await expect(page).toHaveTitle(title); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( @@ -210,30 +283,24 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); const response = await page.goto("/plugins"); expect(response?.status()).toBe(200); - await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); }); // External deployments own their registry contents; these assertions use the local fixture. -test.describe("registry category layout", () => { +test.describe("registry fixture layout", () => { test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); - test("orders populated categories and hides the empty Extras category", async ({ page }) => { + test("lists the nine categories in order with counts, and the newest plugins first", async ({ + page, + }) => { await openPlugins(page); - await expect(page.getByRole("heading", { level: 2 })).toHaveText([ - "Daemon management", - "Themes", - "Providers", - "Orchestration", - "Git", - "Workspaces", - "Sidebar", - "Utils", - ]); - await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0); - for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) { - await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText( - category.description, - ); - } + const categories = page.getByRole("region", { name: "Categories" }); + await expect(categories.getByRole("link")).toHaveText( + CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), + ); + await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); + await expect( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), + ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); }); }); diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 42ee404279..62daa6ebd0 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -3,12 +3,17 @@ import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; interface SiteFooterProps { - width?: "default" | "prose"; + width?: "default" | "prose" | "wide"; } +const WIDTH_CLASSES: Record, string> = { + prose: "max-w-prose p-6 md:p-12 md:pt-0", + default: "max-w-5xl p-6 md:p-20 md:pt-0", + wide: "max-w-7xl p-6 md:p-20 md:pt-0", +}; + export function SiteFooter({ width = "default" }: SiteFooterProps) { - const widthClasses = - width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0"; + const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return (
diff --git a/packages/website/src/components/site-shell.tsx b/packages/website/src/components/site-shell.tsx index 2bbd1f89a4..662f9f50f9 100644 --- a/packages/website/src/components/site-shell.tsx +++ b/packages/website/src/components/site-shell.tsx @@ -4,12 +4,17 @@ import { SiteHeader } from "~/components/site-header"; interface SiteShellProps { children: ReactNode; - width: "default" | "prose"; + width: "default" | "prose" | "wide"; } +const MAIN_CLASSES: Record = { + prose: "max-w-prose p-6 md:p-12 mx-auto", + default: "max-w-5xl p-6 md:p-20 mx-auto", + wide: "max-w-7xl p-6 md:p-20 mx-auto", +}; + export function SiteShell({ children, width }: SiteShellProps) { - const mainClasses = - width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto"; + const mainClasses = MAIN_CLASSES[width]; return (
diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx new file mode 100644 index 0000000000..66782154b6 --- /dev/null +++ b/packages/website/src/plugins/browse-page.tsx @@ -0,0 +1,214 @@ +import { useMemo } from "react"; +import { SiteShell } from "~/components/site-shell"; +import { ContributeLinks } from "./contribute-links"; +import type { InstallCounts } from "./installs"; +import { type BrowseQuery, browseHref } from "./links"; +import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; +import { PluginSearch } from "./plugin-search"; +import { + addedAgo, + CATEGORIES, + type CategorySlug, + getCategory, + getPluginsInCategory, + mostInstalled, + newestFirst, + type Plugin, + searchPlugins, +} from "./registry"; +import { WindowSwitch } from "./window-switch"; + +const NAV_BASE = + "flex items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-sm transition-colors"; +const NAV_ON = `${NAV_BASE} bg-white/[0.06] text-white`; +const NAV_OFF = `${NAV_BASE} text-muted-foreground hover:text-foreground`; +const CHIP_BASE = + "inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors"; +const CHIP_ON = `${CHIP_BASE} border-white/20 bg-white/[0.07] text-white`; +const CHIP_OFF = `${CHIP_BASE} border-white/10 text-muted-foreground`; +const TAB_BASE = "-mb-px border-b pb-2 text-sm transition-colors"; +const TAB_ON = `${TAB_BASE} border-white text-white`; +const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text-foreground`; +const CLEAR_CLASS = + "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const CONTRIBUTE_CLASS = + "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */ +export function BrowsePage({ + plugins, + installs, + now, + query, +}: { + plugins: Plugin[]; + installs: Record; + now: string; + query: BrowseQuery; +}) { + const category = query.category ? getCategory(query.category) : null; + const matches = query.q ? searchPlugins(plugins, query.q) : plugins; + const scoped = category ? getPluginsInCategory(matches, category.slug) : matches; + const results = + query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window); + const title = category?.label ?? "All plugins"; + const clearHref = browseHref({ ...query, q: undefined }); + return ( + +
+ +
+
+

+ {query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} + + {scoped.length} + +

+ {query.q && ( + + Clear + + )} +
+ + {results.length === 0 && query.q && ( +
+

No plugins match.

+ + Clear filters + +
+ )} + {results.length === 0 && !query.q && ( +

No plugins in this category yet.

+ )} + {results.length > 0 && ( +
+ {results.map((plugin) => + query.sort === "new" ? ( + + ) : ( + + ), + )} +
+ )} +
+
+
+ ); +} + +interface NavEntry { + href: string; + label: string; + chip: string; + /** Absent for all plugins. */ + slug?: CategorySlug; + count: number; +} + +/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */ +function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) { + const entries: NavEntry[] = [ + { + href: browseHref({ ...query, category: undefined }), + label: "All plugins", + chip: "All", + count: plugins.length, + }, + ...CATEGORIES.map((category) => ({ + href: browseHref({ ...query, category: category.slug }), + label: category.label, + chip: category.label, + slug: category.slug, + count: getPluginsInCategory(plugins, category.slug).length, + })), + ]; + return ( + <> + + + + ); +} + +function SortTabs({ query }: { query: BrowseQuery }) { + const windowHrefs = useMemo( + () => ({ + week: browseHref({ ...query, window: "week" }), + month: browseHref({ ...query, window: "month" }), + all: browseHref({ ...query, window: "all" }), + }), + [query], + ); + const windowSwitch = query.sort === "installs" && ( + + ); + return ( + <> +
+ + {windowSwitch &&
{windowSwitch}
} +
+ {windowSwitch &&
{windowSwitch}
} + + ); +} diff --git a/packages/website/src/plugins/category-link.tsx b/packages/website/src/plugins/category-link.tsx deleted file mode 100644 index d51dbebd25..0000000000 --- a/packages/website/src/plugins/category-link.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { type ReactNode, useMemo } from "react"; -import type { CategorySlug, PluginSort } from "./registry"; - -interface CategoryLinkProps { - /** `null` links to the unfiltered list. */ - category: CategorySlug | null; - q?: string; - sort?: PluginSort; - replace?: boolean; - className?: string; - children: ReactNode; -} - -/** Link into the plugin list filtered by category, keeping any search and sort. */ -export function CategoryLink({ - category, - q, - sort, - replace, - className, - children, -}: CategoryLinkProps) { - const search = useMemo(() => { - const next: { category?: CategorySlug; q?: string; sort?: PluginSort } = {}; - if (category) next.category = category; - if (q) next.q = q; - if (sort === "new") next.sort = sort; - return next; - }, [category, q, sort]); - return ( - - {children} - - ); -} diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx new file mode 100644 index 0000000000..cb9f712190 --- /dev/null +++ b/packages/website/src/plugins/contribute-links.tsx @@ -0,0 +1,17 @@ +import { BUILD_URL, SUBMIT_URL } from "./links"; + +const LINK_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground"; + +/** "Build a plugin" and "Submit a plugin", as two quiet links. */ +export function ContributeLinks({ className = LINK_CLASS }: { className?: string }) { + return ( + <> + + Build a plugin + + + Submit a plugin + + + ); +} diff --git a/packages/website/src/plugins/installs.ts b/packages/website/src/plugins/installs.ts new file mode 100644 index 0000000000..01846e6797 --- /dev/null +++ b/packages/website/src/plugins/installs.ts @@ -0,0 +1,73 @@ +// Install counts live in the website KV. The all-time counter predates the daily record and +// stays the "All time" number; the daily record keeps the last 60 days for the shorter windows. +export type InstallWindow = "week" | "month" | "all"; +export type InstallCounts = Record; +/** UTC day (YYYY-MM-DD) to installs that day. */ +export type DailyInstalls = Record; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DAYS_KEPT = 60; +const WINDOW_DAYS = { week: 7, month: 30 } as const; + +const totalKey = (id: string) => `plugin-installs:${id}`; +const dailyKey = (id: string) => `plugin-installs-daily:${id}`; + +export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise { + const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); + await Promise.all([ + cache.put(totalKey(id), String(Number(total ?? 0) + 1)), + cache.put(dailyKey(id), JSON.stringify(addInstall(daily, now))), + ]); +} + +export async function readInstallCounts( + cache: KVNamespace | null, + ids: string[], + now: Date, +): Promise> { + const entries = await Promise.all( + ids.map(async (id) => { + if (!cache) return [id, countInstalls({}, 0, now)] as const; + const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); + return [id, countInstalls(daily, Number(total ?? 0), now)] as const; + }), + ); + return Object.fromEntries(entries); +} + +export function addInstall(daily: DailyInstalls, now: Date): DailyInstalls { + const oldest = utcDay(now, DAYS_KEPT - 1); + const kept = Object.fromEntries(Object.entries(daily).filter(([day]) => day >= oldest)); + const today = utcDay(now, 0); + kept[today] = (kept[today] ?? 0) + 1; + return kept; +} + +export function countInstalls(daily: DailyInstalls, total: number, now: Date): InstallCounts { + return { + week: installsSince(daily, utcDay(now, WINDOW_DAYS.week - 1)), + month: installsSince(daily, utcDay(now, WINDOW_DAYS.month - 1)), + all: total, + }; +} + +function installsSince(daily: DailyInstalls, oldest: string): number { + return Object.entries(daily).reduce( + (sum, [day, count]) => (day >= oldest ? sum + count : sum), + 0, + ); +} + +function utcDay(now: Date, daysBefore: number): string { + return new Date(now.getTime() - daysBefore * DAY_MS).toISOString().slice(0, 10); +} + +async function readDaily(cache: KVNamespace, id: string): Promise { + const value: unknown = await cache.get(dailyKey(id), { type: "json" }); + if (typeof value !== "object" || value === null) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => typeof entry[1] === "number", + ), + ); +} diff --git a/packages/website/src/plugins/links.ts b/packages/website/src/plugins/links.ts new file mode 100644 index 0000000000..f55508601d --- /dev/null +++ b/packages/website/src/plugins/links.ts @@ -0,0 +1,48 @@ +import type { CategorySlug } from "./categories"; +import type { InstallWindow } from "./installs"; + +export const BUILD_URL = "/docs/plugins"; +export const SUBMIT_URL = + "https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml"; + +export type BrowseSort = "installs" | "new"; +export interface BrowseQuery { + /** Absent for all plugins. */ + category?: CategorySlug; + sort: BrowseSort; + window: InstallWindow; + /** Search term; absent when not searching. */ + q?: string; +} + +export const DEFAULT_WINDOW: InstallWindow = "week"; + +export function parseWindow(value: unknown): InstallWindow | undefined { + return value === "month" || value === "all" ? value : undefined; +} +export function parseSearchTerm(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} +export function parseSort(value: unknown): BrowseSort | undefined { + return value === "new" ? value : undefined; +} + +export function pluginHref(id: string): string { + return `/plugins/${id}`; +} +export function categoryHref(category: CategorySlug): string { + return `/plugins/category/${category}`; +} +export function browseHref({ category, sort, window, q }: BrowseQuery): string { + const path = category ? categoryHref(category) : "/plugins/all"; + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (sort === "new") params.set("sort", sort); + else if (window !== DEFAULT_WINDOW) params.set("window", window); + const search = params.toString(); + return search ? `${path}?${search}` : path; +} +/** The directory's Most installed section in a time window. */ +export function mostInstalledHref(window: InstallWindow): string { + return `/plugins${window === DEFAULT_WINDOW ? "" : `?window=${window}`}#most-installed`; +} diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts index 617cd5e9ed..27416a4125 100644 --- a/packages/website/src/plugins/loader.ts +++ b/packages/website/src/plugins/loader.ts @@ -1,14 +1,25 @@ import { createServerFn } from "@tanstack/react-start"; import { env } from "cloudflare:workers"; import { getWebsiteCacheContext } from "~/cloudflare-cache"; -import { loadRegistryIndex, loadRegistryPlugin, type RegistryEnvironment } from "./published"; +import { + loadInstallCounts, + loadRegistryIndex, + loadRegistryPlugin, + type RegistryEnvironment, +} from "./published"; function registryBase(): string { return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; } -export const getRegistry = createServerFn({ method: "GET" }).handler(() => - loadRegistryIndex(registryBase(), getWebsiteCacheContext()), -); +/** Listed plugins in index order, their install counts per window, and the server's clock. */ +export const getRegistry = createServerFn({ method: "GET" }).handler(async () => { + const context = getWebsiteCacheContext(); + const [index, installs] = await Promise.all([ + loadRegistryIndex(registryBase(), context), + loadInstallCounts(registryBase(), context), + ]); + return { plugins: index.plugins, installs, now: new Date().toISOString() }; +}); export const getRegistryPlugin = createServerFn({ method: "GET" }) .validator((id: string) => id) .handler(({ data }) => loadRegistryPlugin(registryBase(), data, getWebsiteCacheContext())); diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index 6c3502f78c..d8fdb01fc4 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,49 +1,116 @@ -import { Link } from "@tanstack/react-router"; import { Download } from "lucide-react"; -import { useMemo } from "react"; +import { pluginHref } from "./links"; import { PluginTile } from "./plugin-tile"; -import { formatInstalls, getAuthor, getCategory, type Plugin } from "./registry"; +import { formatInstalls, getCategory, type Plugin } from "./registry"; -export const PLUGIN_GRID_CLASS = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3"; +export const PLUGIN_GRID_CLASS = + "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; -export function PluginCard({ plugin }: { plugin: Plugin }) { - const author = getAuthor(plugin); - const category = getCategory(plugin.categories[0]); - const params = useMemo(() => { - const [owner, slug] = plugin.id.split("/"); - return { owner, slug }; - }, [plugin.id]); +/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */ +function PluginShot({ plugin }: { plugin: Plugin }) { + const url = plugin.screenshots[0]; return ( - -
+ {url ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +/** What's new card: screenshot, name, and when it was added. */ +export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) { + return ( + + +

{plugin.name}

- {author &&

{author.name}

} +

Added {added}

-

- {plugin.description} -

-
- {category ? ( - - {category.label} - - ) : ( - - )} - {plugin.installs !== undefined && ( - - - {formatInstalls(plugin.installs)} installs - - )} + + ); +} + +/** Grid card: screenshot, name, one muted stat, and a two-line description. */ +export function PluginCard({ + plugin, + installs, + added, +}: { + plugin: Plugin; + /** Shown with a download icon; otherwise `added` is shown. */ + installs?: number; + added?: string; +}) { + return ( + + +
+ +
+
+

{plugin.name}

+ + {installs === undefined ? ( + added + ) : ( + <> + + {formatInstalls(installs)} + + )} + +
+

+ {plugin.description} +

+
- +
+ ); +} + +/** Ranked row for the directory's Most installed list. */ +export function PluginRankRow({ + plugin, + rank, + installs, +}: { + plugin: Plugin; + rank: number; + installs: number; +}) { + return ( + + + {rank} + + +
+

{plugin.name}

+

+ {getCategory(plugin.categories[0])?.label} +

+
+ {formatInstalls(installs)} +
); } diff --git a/packages/website/src/plugins/plugin-page-header.tsx b/packages/website/src/plugins/plugin-page-header.tsx deleted file mode 100644 index 9547d2fdda..0000000000 --- a/packages/website/src/plugins/plugin-page-header.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ShieldAlert } from "lucide-react"; - -export function PluginsTrustNote() { - return ( -

- - Plugins run unsandboxed on your machine. Read the source before you install. -

- ); -} diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx new file mode 100644 index 0000000000..a6bd398a19 --- /dev/null +++ b/packages/website/src/plugins/plugin-search.tsx @@ -0,0 +1,53 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { type ChangeEvent, type FormEvent, useCallback, useState } from "react"; +import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links"; + +/** + * Search box for the plugin pages. Typing replaces the URL with the browse page for the term, + * keeping `scope`'s category and sort. Without JavaScript the form submits the same URL. + */ +export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) { + const navigate = useNavigate(); + const [term, setTerm] = useState(scope.q ?? ""); + const handleChange = useCallback( + (event: ChangeEvent) => { + const next = event.target.value; + setTerm(next); + void navigate({ + href: browseHref({ ...scope, q: next.trim() ? next : undefined }), + replace: true, + }); + }, + [navigate, scope], + ); + const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []); + return ( +
+ {scope.sort === "new" && } + {scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && ( + + )} + +
+ ); +} diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index 9d8697aabb..4d6826f11e 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -4,6 +4,8 @@ import { PublishedPluginDetailSchema, } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +import { CATEGORIES } from "./categories"; +import { type InstallCounts, readInstallCounts, recordInstall } from "./installs"; export interface RegistryEnvironment { PLUGINS_REGISTRY_URL?: string; WEBSITE_CACHE?: KVNamespace; @@ -23,6 +25,20 @@ export function loadRegistryIndex(base: string, context: WebsiteCacheContext) { fetchFresh: async () => PluginRegistryIndexSchema.parse(await documentAt(base, "index.json")), }); } +/** Install counts per window for every listed plugin, refreshed with the index cache. */ +export async function loadInstallCounts(base: string, context: WebsiteCacheContext) { + const index = await loadRegistryIndex(base, context); + const ids = index.plugins.map((plugin) => plugin.id); + return getBlockingColdCache({ + context, + key: `plugins:installs:v1:${base}`, + isValue: (value): value is Record => + typeof value === "object" && + value !== null && + Object.values(value).every((counts) => typeof counts?.all === "number"), + fetchFresh: () => readInstallCounts(context.cache, ids, new Date()), + }); +} export async function loadRegistryPlugin(base: string, id: string, context: WebsiteCacheContext) { if (!PluginRegistryIdSchema.safeParse(id).success) return null; return getBlockingColdCache({ @@ -52,7 +68,11 @@ export async function handlePluginRegistryRequest( if (url.pathname === "/sitemap-plugins.xml") { const index = await loadRegistryIndex(base, context); // IDs are validated owner/slug strings, so these paths contain no XML metacharacters. - const paths = new Set(["/plugins"]); + const paths = new Set(["/plugins", "/plugins/all"]); + for (const category of CATEGORIES) { + if (index.plugins.some((plugin) => plugin.categories.includes(category.slug))) + paths.add(`/plugins/category/${category.slug}`); + } for (const plugin of index.plugins) { paths.add(`/plugins/${plugin.id.split("/")[0]}`); paths.add(`/plugins/${plugin.id}`); @@ -70,16 +90,14 @@ export async function handlePluginRegistryRequest( } if (url.pathname === "/api/plugins/installs") { const index = await loadRegistryIndex(base, context); - const entries = await Promise.all( - index.plugins.map( - async (plugin) => - [ - plugin.id, - Number((await context.cache?.get(`plugin-installs:${plugin.id}`)) ?? 0), - ] as const, - ), + const counts = await readInstallCounts( + context.cache, + index.plugins.map((plugin) => plugin.id), + new Date(), + ); + return Response.json( + Object.fromEntries(Object.entries(counts).map(([id, count]) => [id, count.all])), ); - return Response.json(Object.fromEntries(entries)); } const match = /^\/api\/plugins\/resolve\/([^/]+\/[^/]+)$/.exec(url.pathname) ?? @@ -93,14 +111,7 @@ export async function handlePluginRegistryRequest( url.searchParams.get("intent") === "install" ) { const cache = context.cache; - if (cache) - context.waitUntil( - (async () => { - const key = `plugin-installs:${id}`; - const count = Number((await cache.get(key)) ?? 0); - await cache.put(key, String(count + 1)); - })().catch(() => undefined), - ); + if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined)); } return Response.json(plugin); } diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index ed5963fb0a..d5244fcb3f 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from "vitest"; import { formatInstalls, installCommand, - pinnedInstallCommand, - queryPlugins, + mostInstalled, + newestFirst, type Plugin, + readmeBody, + searchPlugins, } from "./registry"; +import { readInstallCounts, recordInstall } from "./installs"; import { loadRegistryIndex, loadRegistryPlugin } from "./published"; const plugin: Plugin = { id: "acme/example", @@ -56,29 +59,82 @@ describe("plugin registry", () => { await new Promise((resolve) => server.close(() => resolve())); } }); - it("filters namespaced IDs and ranks by installs", () => { - const popular = { ...plugin, id: "acme/popular", installs: 100 }; - expect(queryPlugins([plugin, popular], { sort: "popular" }).map((p) => p.id)).toEqual([ - "acme/popular", + it("orders by submission date and by installs in a window, keeping index order for ties", () => { + const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" }; + const twin = { ...plugin, id: "acme/twin" }; + expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([ "acme/example", + "acme/twin", + "acme/older", ]); - expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]); - expect(queryPlugins([plugin], { category: "utils", sort: "popular" })).toEqual([]); + const installs = { + "acme/example": { week: 1, month: 5, all: 9 }, + "acme/older": { week: 3, month: 3, all: 3 }, + "acme/twin": { week: 1, month: 1, all: 1 }, + }; + expect(mostInstalled([plugin, older, twin], installs, "week").map((p) => p.id)).toEqual([ + "acme/older", + "acme/example", + "acme/twin", + ]); + expect(mostInstalled([plugin, older, twin], installs, "month")[0].id).toBe("acme/example"); }); - it("offers a registry command and an exact explicit command", () => { - expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); - expect(pinnedInstallCommand(plugin)).toBe("paseo plugin install npm:paseo-example@1.2.3"); + it("counts installs this week, this month, and all time from the daily record", async () => { + const cache = memoryKv(); + const day = (date: string) => new Date(`${date}T12:00:00.000Z`); + await cache.put("plugin-installs:acme/example", "40"); + await recordInstall(cache, "acme/example", day("2026-07-01")); + await recordInstall(cache, "acme/example", day("2026-09-10")); + await recordInstall(cache, "acme/example", day("2026-09-30")); + await recordInstall(cache, "acme/example", day("2026-10-04")); + expect(await readInstallCounts(cache, ["acme/example"], day("2026-10-04"))).toEqual({ + "acme/example": { week: 2, month: 3, all: 44 }, + }); expect( - pinnedInstallCommand({ - ...plugin, - artifact: { - kind: "git", - remote: "https://github.com/acme/plugins.git", - commit: "a".repeat(40), - pluginPath: "packages/example", - }, - }), - ).toBe(`paseo plugin install github:acme/plugins:packages/example --ref ${"a".repeat(40)}`); + Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")), + ).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]); + }); + it("searches name, description, ID, and author, ignoring case", () => { + const other = { + ...plugin, + id: "zed/other", + name: "Other", + description: "Unrelated", + author: { github: "zed" }, + }; + const plugins = [plugin, other]; + expect(searchPlugins(plugins, "EXAMPLE").map((p) => p.id)).toEqual(["acme/example"]); + expect(searchPlugins(plugins, "unrelated").map((p) => p.id)).toEqual(["zed/other"]); + expect(searchPlugins(plugins, "zed/").map((p) => p.id)).toEqual(["zed/other"]); + expect(searchPlugins(plugins, "acme").map((p) => p.id)).toEqual(["acme/example"]); + expect(searchPlugins(plugins, " ")).toEqual(plugins); + expect(searchPlugins(plugins, "nothing")).toEqual([]); + }); + + it("strips the README title and quoted description that the page already shows", () => { + expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n"); + expect(readmeBody("## Usage\n\n> Note\n")).toBe("## Usage\n\n> Note\n"); + expect(readmeBody("# Example\n\n> [!WARNING]\n> Needs Docker\n\n## Usage\n")).toBe( + "> [!WARNING]\n> Needs Docker\n\n## Usage\n", + ); + }); + it("offers a registry install command", () => { + expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); expect(formatInstalls(1250)).toBe("1.3k"); }); }); + +/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */ +function memoryKv(): KVNamespace { + const values = new Map(); + const kv = { + get: async (key: string, options?: { type?: string }) => { + const value = values.get(key) ?? null; + return options?.type === "json" && value !== null ? JSON.parse(value) : value; + }, + put: async (key: string, value: string) => { + values.set(key, value); + }, + }; + return kv as unknown as KVNamespace; +} diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index 33f822cd3f..933399b3a2 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,7 +1,9 @@ import type { z } from "zod"; import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; import { CATEGORIES, type Category, type CategorySlug } from "./categories"; +import type { InstallCounts, InstallWindow } from "./installs"; export { CATEGORIES, type Category, type CategorySlug }; +export type { InstallCounts, InstallWindow }; export type Plugin = z.infer; export interface Author { username: string; @@ -9,7 +11,7 @@ export interface Author { github: string; npm?: string; } -export type PluginSort = "popular" | "new"; +const DAY_MS = 24 * 60 * 60 * 1000; export function getCategory(slug: string): Category | null { return CATEGORIES.find((category) => category.slug === slug) ?? null; } @@ -35,16 +37,6 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { export function installCommand(plugin: Plugin): string { return `paseo plugin install ${plugin.id}`; } -export function pinnedInstallCommand(plugin: Plugin): string { - const artifact = plugin.artifact; - if (artifact.kind === "npm") - return `paseo plugin install npm:${artifact.package}@${artifact.version}`; - const source = artifact.remote - .replace(/^https:\/\/github.com\//, "github:") - .replace(/\.git$/, ""); - const location = artifact.pluginPath ? `${source}:${artifact.pluginPath}` : source; - return `paseo plugin install ${location} --ref ${artifact.commit}`; -} export function pluginVersion(plugin: Plugin): string { return plugin.artifact.kind === "npm" ? plugin.artifact.version @@ -69,26 +61,45 @@ export function formatInstalls(count: number): string { const k = count / 1000; return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; } -export interface PluginQuery { - category?: CategorySlug; - q?: string; - sort: PluginSort; -} -export function queryPlugins(plugins: Plugin[], query: PluginQuery): Plugin[] { - const needle = query.q?.trim().toLowerCase() ?? ""; - const matches = plugins.filter( - (plugin) => - (!query.category || plugin.categories.includes(query.category)) && - (!needle || - `${plugin.name} ${plugin.description} ${plugin.id}`.toLowerCase().includes(needle)), - ); - return sortPlugins(matches, query.sort); -} -export function sortPlugins(list: Plugin[], sort: PluginSort): Plugin[] { - return [...list].sort( - (a, b) => - (sort === "new" - ? b.updatedAt.localeCompare(a.updatedAt) - : (b.installs ?? 0) - (a.installs ?? 0)) || a.name.localeCompare(b.name), +/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */ +export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { + const needle = term.trim().toLowerCase(); + if (!needle) return plugins; + return plugins.filter((plugin) => + [plugin.name, plugin.description, plugin.id, getAuthor(plugin).name] + .join(" ") + .toLowerCase() + .includes(needle), ); } +/** Newest submissions first; equal dates keep index order. */ +export function newestFirst(plugins: Plugin[]): Plugin[] { + return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); +} +/** Most installs in the window first; equal counts keep index order. */ +export function mostInstalled( + plugins: Plugin[], + installs: Record, + window: InstallWindow, +): Plugin[] { + const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0; + return [...plugins].sort((a, b) => count(b) - count(a)); +} +/** How long ago the registry accepted the plugin, relative to the loader's clock. */ +export function addedAgo(plugin: Plugin, now: string): string { + const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 14) return `${days}d ago`; + return `${Math.round(days / 7)}w ago`; +} +/** + * A README usually opens with the plugin's name and quoted description; the page shows both. + * A leading GitHub alert (`> [!WARNING]`) is a note for the reader and stays. + */ +export function readmeBody(readme: string): string { + return readme + .replace(/^\s*#\s[^\n]*\n+/, "") + .replace(/^(?!>\s*\[!)(>[^\n]*\n)+\n*/, "") + .trimStart(); +} diff --git a/packages/website/src/plugins/window-switch.tsx b/packages/website/src/plugins/window-switch.tsx new file mode 100644 index 0000000000..c4098f097f --- /dev/null +++ b/packages/website/src/plugins/window-switch.tsx @@ -0,0 +1,35 @@ +import type { InstallWindow } from "./installs"; + +const WINDOWS: { value: InstallWindow; label: string }[] = [ + { value: "week", label: "This week" }, + { value: "month", label: "This month" }, + { value: "all", label: "All time" }, +]; + +/** Plain text time-window switch for install rankings. */ +export function WindowSwitch({ + current, + hrefs, +}: { + current: InstallWindow; + hrefs: Record; +}) { + return ( + + ); +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index a9b668c187..46b5da4363 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -63,6 +63,7 @@ import { Route as IndexRouteImport } from "./routes/index"; import { Route as PluginsIndexRouteImport } from "./routes/plugins/index"; import { Route as DocsIndexRouteImport } from "./routes/docs/index"; import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as PluginsAllRouteImport } from "./routes/plugins/all"; import { Route as PluginsOwnerRouteImport } from "./routes/plugins/$owner"; import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks"; import { Route as DocsSplatRouteImport } from "./routes/docs/$"; @@ -75,6 +76,7 @@ import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternative import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor"; import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app"; import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop"; +import { Route as PluginsCategorySlugRouteImport } from "./routes/plugins/category/$slug"; import { Route as PluginsOwnerSlugRouteImport } from "./routes/plugins/$owner_.$slug"; const VtcodeRoute = VtcodeRouteImport.update({ @@ -347,6 +349,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: "/", getParentRoute: () => BlogRoute, } as any); +const PluginsAllRoute = PluginsAllRouteImport.update({ + id: "/plugins/all", + path: "/plugins/all", + getParentRoute: () => rootRouteImport, +} as any); const PluginsOwnerRoute = PluginsOwnerRouteImport.update({ id: "/plugins/$owner", path: "/plugins/$owner", @@ -409,6 +416,11 @@ const AlternativesClaudeDesktopRoute = path: "/alternatives/claude-desktop", getParentRoute: () => rootRouteImport, } as any); +const PluginsCategorySlugRoute = PluginsCategorySlugRouteImport.update({ + id: "/plugins/category/$slug", + path: "/plugins/category/$slug", + getParentRoute: () => rootRouteImport, +} as any); const PluginsOwnerSlugRoute = PluginsOwnerSlugRouteImport.update({ id: "/plugins/$owner_/$slug", path: "/plugins/$owner/$slug", @@ -479,10 +491,12 @@ export interface FileRoutesByFullPath { "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; "/plugins/": typeof PluginsIndexRoute; "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; @@ -546,10 +560,12 @@ export interface FileRoutesByTo { "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog": typeof BlogIndexRoute; "/docs": typeof DocsIndexRoute; "/plugins": typeof PluginsIndexRoute; "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; @@ -616,10 +632,12 @@ export interface FileRoutesById { "/docs/$": typeof DocsSplatRoute; "/download_/thanks": typeof DownloadThanksRoute; "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; "/plugins/": typeof PluginsIndexRoute; "/plugins/$owner_/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; @@ -687,10 +705,12 @@ export interface FileRouteTypes { | "/docs/$" | "/download/thanks" | "/plugins/$owner" + | "/plugins/all" | "/blog/" | "/docs/" | "/plugins/" - | "/plugins/$owner/$slug"; + | "/plugins/$owner/$slug" + | "/plugins/category/$slug"; fileRoutesByTo: FileRoutesByTo; to: | "/" @@ -754,10 +774,12 @@ export interface FileRouteTypes { | "/docs/$" | "/download/thanks" | "/plugins/$owner" + | "/plugins/all" | "/blog" | "/docs" | "/plugins" - | "/plugins/$owner/$slug"; + | "/plugins/$owner/$slug" + | "/plugins/category/$slug"; id: | "__root__" | "/" @@ -823,10 +845,12 @@ export interface FileRouteTypes { | "/docs/$" | "/download_/thanks" | "/plugins/$owner" + | "/plugins/all" | "/blog/" | "/docs/" | "/plugins/" - | "/plugins/$owner_/$slug"; + | "/plugins/$owner_/$slug" + | "/plugins/category/$slug"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { @@ -891,8 +915,10 @@ export interface RootRouteChildren { AlternativesSupersetRoute: typeof AlternativesSupersetRoute; DownloadThanksRoute: typeof DownloadThanksRoute; PluginsOwnerRoute: typeof PluginsOwnerRoute; + PluginsAllRoute: typeof PluginsAllRoute; PluginsIndexRoute: typeof PluginsIndexRoute; PluginsOwnerSlugRoute: typeof PluginsOwnerSlugRoute; + PluginsCategorySlugRoute: typeof PluginsCategorySlugRoute; } declare module "@tanstack/react-router" { @@ -1275,6 +1301,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BlogIndexRouteImport; parentRoute: typeof BlogRoute; }; + "/plugins/all": { + id: "/plugins/all"; + path: "/plugins/all"; + fullPath: "/plugins/all"; + preLoaderRoute: typeof PluginsAllRouteImport; + parentRoute: typeof rootRouteImport; + }; "/plugins/$owner": { id: "/plugins/$owner"; path: "/plugins/$owner"; @@ -1359,6 +1392,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/category/$slug": { + id: "/plugins/category/$slug"; + path: "/plugins/category/$slug"; + fullPath: "/plugins/category/$slug"; + preLoaderRoute: typeof PluginsCategorySlugRouteImport; + parentRoute: typeof rootRouteImport; + }; "/plugins/$owner_/$slug": { id: "/plugins/$owner_/$slug"; path: "/plugins/$owner/$slug"; @@ -1455,8 +1495,10 @@ const rootRouteChildren: RootRouteChildren = { AlternativesSupersetRoute: AlternativesSupersetRoute, DownloadThanksRoute: DownloadThanksRoute, PluginsOwnerRoute: PluginsOwnerRoute, + PluginsAllRoute: PluginsAllRoute, PluginsIndexRoute: PluginsIndexRoute, PluginsOwnerSlugRoute: PluginsOwnerSlugRoute, + PluginsCategorySlugRoute: PluginsCategorySlugRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index 562d1d91cc..e4db39a205 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -10,12 +10,12 @@ import { getAuthor, getRegistry, getPluginsByAuthor, + mostInstalled, pluginOwner, - sortPlugins, } from "~/plugins"; import { AuthorAvatar } from "~/plugins/author-link"; import { PluginsNotFound } from "~/plugins/not-found"; -import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import { PluginCard } from "~/plugins/plugin-card"; import "~/styles.css"; export const Route = createFileRoute("/plugins/$owner")({ @@ -23,7 +23,7 @@ export const Route = createFileRoute("/plugins/$owner")({ const registry = await getRegistry(); const first = registry.plugins.find((plugin) => pluginOwner(plugin) === params.owner); if (!first) throw notFound(); - return { plugins: registry.plugins, author: getAuthor(first) }; + return { plugins: registry.plugins, installs: registry.installs, author: getAuthor(first) }; }, head: ({ params, loaderData }) => pageMeta( @@ -43,13 +43,13 @@ const LINK_CLASS = "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; function AuthorPage() { - const { plugins: allPlugins, author } = Route.useLoaderData(); + const { plugins: allPlugins, installs, author } = Route.useLoaderData(); const crumbs = useMemo( () => [{ label: "Plugins", href: "/plugins" }, { label: author.name }], [author], ); - const plugins = sortPlugins(getPluginsByAuthor(allPlugins, author.username), "popular"); + const plugins = mostInstalled(getPluginsByAuthor(allPlugins, author.username), installs, "all"); const github = authorGitHubUrl(author); return ( @@ -84,9 +84,9 @@ function AuthorPage() {
-
+
{plugins.map((plugin) => ( - + ))}
diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index ec14392db2..b70a13d9da 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,7 +1,5 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; -import { Download, ExternalLink } from "lucide-react"; -import { useMemo } from "react"; -import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { ExternalLink } from "lucide-react"; import { CodeBlock } from "~/components/code-block"; import { DocsMarkdown } from "~/components/docs-markdown"; import { SiteShell } from "~/components/site-shell"; @@ -12,25 +10,16 @@ import { getCategory, getRegistry, getRegistryPlugin, - pinnedInstallCommand, - pluginVersion, - getPluginsByAuthor, - getPluginsInCategory, installCommand, npmUrl, - type Plugin, - sortPlugins, + pluginVersion, + readmeBody, } from "~/plugins"; import { AuthorLink } from "~/plugins/author-link"; -import { CategoryLink } from "~/plugins/category-link"; +import { categoryHref } from "~/plugins/links"; import { PluginsNotFound } from "~/plugins/not-found"; -import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; -import { PluginsTrustNote } from "~/plugins/plugin-page-header"; -import { PluginTile } from "~/plugins/plugin-tile"; import "~/styles.css"; -const RELATED_COUNT = 3; - export const Route = createFileRoute("/plugins/$owner_/$slug")({ loader: async ({ params }) => { const [registry, plugin] = await Promise.all([ @@ -38,7 +27,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ getRegistryPlugin({ data: `${params.owner}/${params.slug}` }), ]); if (!plugin) throw notFound(); - return { plugins: registry.plugins, plugin }; + return { plugin, installs: registry.installs[plugin.id]?.all ?? 0 }; }, head: ({ params, loaderData }) => pageMeta( @@ -55,181 +44,79 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ ), }); -const LINK_CLASS = +const META_LINK_CLASS = "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; -const VIEW_ALL_CLASS = - "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; -const CATEGORY_PILL_CLASS = - "rounded-full border border-white/10 px-2 py-1 text-xs text-white/40 transition-colors hover:border-white/20 hover:text-white/70"; function PluginPage() { - const { plugins, plugin } = Route.useLoaderData(); - const primaryCategory = getCategory(plugin.categories[0]); - - const crumbs = useMemo(() => { - return [ - { label: "Plugins", href: "/plugins" }, - ...(primaryCategory - ? [{ label: primaryCategory.label, href: `/plugins?category=${primaryCategory.slug}` }] - : []), - { label: plugin.name }, - ]; - }, [plugin, primaryCategory]); - + const { plugin, installs } = Route.useLoaderData(); + const category = getCategory(plugin.categories[0]); const author = getAuthor(plugin); - const count = plugin.installs; - const readme = plugin.readme; + const npm = npmUrl(plugin); return ( - - -
-
- -
-

{plugin.name}

-

{plugin.description}

-
- {author && } - {count !== undefined && ( - - - {formatInstalls(count)} - installs - - )} - {plugin.categories.map((slug) => { - const category = getCategory(slug); - if (!category) return null; - return ( - - {category.label} - - ); - })} -
-
-
-
+
+ + ← Plugins + +

{plugin.name}

+

{plugin.description}

+
{installCommand(plugin)} -

- {pluginVersion(plugin)} · Updated {plugin.updatedAt.slice(0, 10)} -

-

For older daemons

- {pinnedInstallCommand(plugin)} -
- -
+
+
+ + {author.name} + + {category && ( + + {category.label} + + )} + {formatInstalls(installs)} installs + {pluginVersion(plugin)} + + Source + + + {npm && ( + + npm + + + )} +
+ + {plugin.screenshots.length > 0 && ( +
+ {plugin.screenshots.map((url, index) => ( - Source - + {`${plugin.name} - {npmUrl(plugin) && ( - - npm - - - )} -
+ ))}
+ )} + +
+ {readmeBody(plugin.readme)}
- - {plugin.screenshots.length > 0 && ( -
- {plugin.screenshots.map((url, index) => ( - - {`${plugin.name} - - ))} -
- )} - -
- {readme} -
- - ); } - -function RelatedPlugins({ plugin, plugins }: { plugin: Plugin; plugins: Plugin[] }) { - const category = getCategory(plugin.categories[0]); - const author = getAuthor(plugin); - const inCategory = category - ? sortPlugins( - getPluginsInCategory(plugins, category.slug).filter( - (candidate) => candidate.id !== plugin.id, - ), - "popular", - ).slice(0, RELATED_COUNT) - : []; - const byAuthor = author - ? sortPlugins( - getPluginsByAuthor(plugins, author.username).filter( - (candidate) => candidate.id !== plugin.id && !inCategory.includes(candidate), - ), - "popular", - ).slice(0, RELATED_COUNT) - : []; - - if (inCategory.length === 0 && byAuthor.length === 0) return null; - - return ( -
- {category && inCategory.length > 0 && ( -
-
-

More in {category.label}

- - View all - -
-
- {inCategory.map((candidate) => ( - - ))} -
-
- )} - {author && byAuthor.length > 0 && ( -
-
-

More from {author.name}

- - View all - -
-
- {byAuthor.map((candidate) => ( - - ))} -
-
- )} -
- ); -} diff --git a/packages/website/src/routes/plugins/all.tsx b/packages/website/src/routes/plugins/all.tsx new file mode 100644 index 0000000000..81715f0957 --- /dev/null +++ b/packages/website/src/routes/plugins/all.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { pageMeta } from "~/meta"; +import { getRegistry } from "~/plugins"; +import { BrowsePage } from "~/plugins/browse-page"; +import { + type BrowseQuery, + DEFAULT_WINDOW, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/all")({ + validateSearch: (search: Record): Omit, "category"> => ({ + sort: parseSort(search.sort), + window: parseWindow(search.window), + q: parseSearchTerm(search.q), + }), + head: () => + pageMeta( + "All plugins – Paseo plugins", + "Every community plugin for Paseo, by installs or newest first.", + "/plugins/all", + ), + loader: () => getRegistry(), + component: AllPluginsPage, +}); + +function AllPluginsPage() { + const { plugins, installs, now } = Route.useLoaderData(); + const search = Route.useSearch(); + const query = useMemo( + () => ({ + sort: search.sort ?? "installs", + window: search.window ?? DEFAULT_WINDOW, + q: search.q, + }), + [search.sort, search.window, search.q], + ); + return ; +} diff --git a/packages/website/src/routes/plugins/category/$slug.tsx b/packages/website/src/routes/plugins/category/$slug.tsx new file mode 100644 index 0000000000..53234bc528 --- /dev/null +++ b/packages/website/src/routes/plugins/category/$slug.tsx @@ -0,0 +1,52 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { pageMeta } from "~/meta"; +import { getCategory, getRegistry } from "~/plugins"; +import { BrowsePage } from "~/plugins/browse-page"; +import { + type BrowseQuery, + DEFAULT_WINDOW, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +import { PluginsNotFound } from "~/plugins/not-found"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/category/$slug")({ + validateSearch: (search: Record): Omit, "category"> => ({ + sort: parseSort(search.sort), + window: parseWindow(search.window), + q: parseSearchTerm(search.q), + }), + loader: async ({ params }) => { + const category = getCategory(params.slug); + if (!category) throw notFound(); + return { ...(await getRegistry()), category }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData ? `${loaderData.category.label} – Paseo plugins` : "Category not found – Paseo", + loaderData?.category.description ?? "Category not found.", + `/plugins/category/${params.slug}`, + ), + component: CategoryPage, + notFoundComponent: () => ( + There is no plugin category here. + ), +}); + +function CategoryPage() { + const { plugins, installs, now, category } = Route.useLoaderData(); + const search = Route.useSearch(); + const query = useMemo( + () => ({ + category: category.slug, + sort: search.sort ?? "installs", + window: search.window ?? DEFAULT_WINDOW, + q: search.q, + }), + [category.slug, search.sort, search.window, search.q], + ); + return ; +} diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 0c624c2749..c95273cc60 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -1,40 +1,82 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { Search } from "lucide-react"; -import { type ChangeEvent, useCallback } from "react"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + Blocks, + Boxes, + ChevronRight, + GitBranch, + LayoutPanelLeft, + type LucideIcon, + Network, + Palette, + Server, + Sparkles, + Wrench, +} from "lucide-react"; +import { useMemo } from "react"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; import { + addedAgo, CATEGORIES, type CategorySlug, getCategory, - getRegistry, getPluginsInCategory, - type Plugin, - type PluginSort, - queryPlugins, - sortPlugins, + getRegistry, + type InstallWindow, + mostInstalled, + newestFirst, } from "~/plugins"; -import { CategoryLink } from "~/plugins/category-link"; -import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; -import { PluginsTrustNote } from "~/plugins/plugin-page-header"; +import { ContributeLinks } from "~/plugins/contribute-links"; +import { + type BrowseQuery, + browseHref, + categoryHref, + DEFAULT_WINDOW, + mostInstalledHref, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; +import { PluginSearch } from "~/plugins/plugin-search"; +import { WindowSwitch } from "~/plugins/window-switch"; import "~/styles.css"; -interface PluginsSearch { - category?: CategorySlug; - q?: string; - sort?: PluginSort; -} +const NEW_COUNT = 4; +const TOP_COUNT = 6; -const SECTION_PREVIEW_COUNT = 3; +const CATEGORY_ICONS: Record = { + "daemon-management": Server, + themes: Palette, + providers: Boxes, + orchestration: Network, + git: GitBranch, + workspaces: Blocks, + sidebar: LayoutPanelLeft, + extras: Sparkles, + utils: Wrench, +}; + +const SEE_ALL_CLASS = + "inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground"; export const Route = createFileRoute("/plugins/")({ - validateSearch: (search: Record): PluginsSearch => { - const result: PluginsSearch = {}; - const category = typeof search.category === "string" ? getCategory(search.category) : null; - if (category) result.category = category.slug; - if (typeof search.q === "string" && search.q.trim()) result.q = search.q; - if (search.sort === "new") result.sort = "new"; - return result; + validateSearch: (search: Record): { window?: InstallWindow } => { + const window = parseWindow(search.window); + return window ? { window } : {}; + }, + beforeLoad: ({ location }) => { + // Keep links from before browse pages existed: /plugins?q=&category=&sort=new. + const params = new URLSearchParams(location.searchStr); + const category = getCategory(params.get("category") ?? ""); + const sort = parseSort(params.get("sort")) ?? "installs"; + const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW; + const q = parseSearchTerm(params.get("q")); + if (category || sort === "new" || q) + throw redirect({ + href: browseHref({ category: category?.slug, sort, window, q }), + statusCode: 301, + }); }, head: () => pageMeta( @@ -46,216 +88,107 @@ export const Route = createFileRoute("/plugins/")({ component: PluginsPage, }); -const PILL_BASE = - "inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors"; -const PILL_ACTIVE = `${PILL_BASE} border-white/20 bg-white/[0.07] text-white`; -const PILL_INACTIVE = `${PILL_BASE} border-white/10 bg-white/[0.025] text-muted-foreground hover:border-white/15 hover:bg-white/[0.04] hover:text-foreground`; -const VIEW_ALL_CLASS = - "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; - function PluginsPage() { - const search = Route.useSearch(); - const { plugins } = Route.useLoaderData(); - const navigate = useNavigate({ from: "/plugins/" }); - const sort: PluginSort = search.sort ?? "popular"; - const filtering = Boolean(search.category || search.q); - - const handleSearchChange = useCallback( - (event: ChangeEvent) => { - const q = event.target.value; - void navigate({ - search: (prev) => (q ? { ...prev, q } : { ...prev, q: undefined }), - replace: true, - }); - }, - [navigate], - ); - const handleSort = useCallback( - (next: PluginSort) => { - void navigate({ - search: (prev) => ({ ...prev, sort: next === "new" ? next : undefined }), - replace: true, - }); - }, - [navigate], + const { plugins, installs, now } = Route.useLoaderData(); + const window = Route.useSearch().window ?? DEFAULT_WINDOW; + const newest = newestFirst(plugins).slice(0, NEW_COUNT); + const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT); + const windowHrefs = useMemo( + () => ({ + week: mostInstalledHref("week"), + month: mostInstalledHref("month"), + all: mostInstalledHref("all"), + }), + [], ); + const searchScope = useMemo(() => ({ sort: "installs", window }), [window]); return ( -

Plugins

-

- Themes, providers, panels, and automations built by the Paseo community. Install any of them - with one command. -

- - -
- -
- - Popular - - - New - +
+

+ Plugins + + {plugins.length} + +

+
+ +
-
- - All - - {CATEGORIES.map((category) => { - if (getPluginsInCategory(plugins, category.slug).length === 0) return null; - return ( - - {category.label} - - ); - })} -
+
+
+

+ What’s new +

+ + See all + + +
+
+ {newest.map((plugin) => ( + + ))} +
+
- {filtering ? ( - - ) : ( - - )} +
+

+ Categories +

+
+ {CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category.slug]; + return ( + + + + {category.label} + + + {getPluginsInCategory(plugins, category.slug).length} + + + ); + })} +
+
+ +
+ +
+ {top.map((plugin, index) => ( + + ))} +
+
); } - -function SortButton({ - value, - current, - onSelect, - children, -}: { - value: PluginSort; - current: PluginSort; - onSelect: (sort: PluginSort) => void; - children: string; -}) { - const handleClick = useCallback(() => onSelect(value), [onSelect, value]); - const active = value === current; - return ( - - ); -} - -function CategorySections({ sort, plugins }: { sort: PluginSort; plugins: Plugin[] }) { - return ( -
- {CATEGORIES.map((category) => { - const all = sortPlugins(getPluginsInCategory(plugins, category.slug), sort); - if (all.length === 0) return null; - const shown = all.slice(0, SECTION_PREVIEW_COUNT); - return ( -
-
-
-

- {category.label} -

-

{category.description}

-
- {all.length > shown.length && ( - - View all ({all.length}) - - )} -
-
- {shown.map((plugin) => ( - - ))} -
-
- ); - })} -
- ); -} - -function FilteredPlugins({ - search, - sort, - plugins, -}: { - search: PluginsSearch; - sort: PluginSort; - plugins: Plugin[]; -}) { - const category = search.category ? getCategory(search.category) : null; - const results = queryPlugins(plugins, { category: search.category, q: search.q, sort }); - const total = plugins.length; - - return ( -
-
-

- {category ? category.label : `Results for “${search.q}”`} -

-

- {category && !search.q ? category.description : null} - {search.q && category ? `Matching “${search.q}”.` : null} - {!category && search.q ? `${results.length} of ${total} plugins.` : null} -

-
- {results.length === 0 ? ( -
-

No plugins match.

- - Clear filters - -
- ) : ( -
- {results.map((plugin) => ( - - ))} -
- )} -
- ); -} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 437741eb74..760cb1857d 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2253,12 +2253,11 @@ directory. The app does not expand `~`; your shell may expand it before the CLI | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | -| Registry plugin | `owner/slug` or `host/owner/slug` | `omercnet/dracula` | | Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | -| GitHub repository | `github:owner/repository` | `github:acme/paseo-review` | +| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` | | Git repository | `git:`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:[@]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to an explicit npm/Git source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use `user@host:path`. `file://` selects Git acquisition, not directory installation. @@ -2283,12 +2282,16 @@ Paseo resolves an identifier in this order: across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix that does not satisfy these rules stays part of the identifier. 4. Without an explicit prefix, an existing directory matching the remaining source wins. -5. Resolve bare `owner/slug` and `host/owner/slug` through the plugin registry. Registry records - own the artifact revision and plugin path. GitHub shorthand requires `github:`; `git:` also - accepts shorthand, URLs, and SCP sources. +5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub + HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources. 6. Resolve a remaining npm package name with its optional selector through the host's registry. Reject anything else. +Plugin registry installs are off by default. When the daemon enables them with +`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and +`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns +the revision and plugin path, and GitHub shorthand requires `github:`. + Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI accepts `--id ` to override it. An existing installation ID is rejected without changing its enabled state or files. @@ -2334,9 +2337,9 @@ deletes its managed files; removing a directory plugin keeps your source directo paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id -paseo plugin add github:owner/repository +paseo plugin add owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add github:owner/monorepo:plugins/review +paseo plugin add owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update paseo plugin update --all --check From b3ed7363c6eaa9e889d9f2bf471084da541db385 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:07:57 +0200 Subject: [PATCH 038/281] fix(claude): keep a background subagent's card labeled after its child streams (#6067) Claude Code answers a background Agent call right away, then streams the child's frames. Each frame re-emitted the parent's card from the sidechain tracker after the tool-input cache had dropped the call, replacing the settled, labeled card with a bare running "Task". The tracker now emits the parent card only while the parent's tool call is still open. Closes #6059 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.background-subagent.test.ts | 121 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 4 + 2 files changed, 125 insertions(+) diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index a767dc9c2a..630bdbf0a6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -314,6 +314,127 @@ describe("background Claude subagents", () => { }); }); + test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => { + // Claude Code answers a background Agent call at once ("Agent launched") and only then streams + // the child's frames, so they arrive after the parent's tool call has its result. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const rows: AgentTimelineRow[] = events + .filter((event) => event.type === "timeline") + .map((event, index) => ({ + seq: index + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(), + item: event.item, + })); + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }); + test.each([ { source: "system task protocol", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 36f991d019..10048356b1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2092,7 +2092,11 @@ class ClaudeAgentSession implements AgentSession { // identity and status from frames for them. Detecting the capability beats comparing version // strings: it reacts to what this session actually does. isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, + // The parent's tool call owns its card once its result arrives. A background child streams + // its frames after Claude has already answered the call, and re-emitting the card from them + // would replace the settled card with an unlabeled, running one. needsSyntheticParentToolCard: (toolUseId) => + this.toolUseCache.has(toolUseId) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), }); private persistedHistory: PersistedTimelineEntry[] = []; From 8a4ce5a482e73a8d35151b2555aa789cfa162c8a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 21:15:23 +0100 Subject: [PATCH 039/281] fix(website): replace the native search clear control with a muted X button (#6082) --- packages/website/e2e/plugins.spec.ts | 16 ++++++++ .../website/src/plugins/plugin-search.tsx | 41 +++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index d2066bca61..92a48d0c84 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -104,6 +104,22 @@ test("keeps the directory's ranking window when searching", async ({ page }) => ); }); +test("clears the search with the clear button", async ({ page }) => { + await page.goto("/plugins/all"); + const searchbox = page.getByRole("searchbox", { name: "Search plugins" }); + const clear = page.getByRole("button", { name: "Clear search" }); + await expect(clear).toHaveCount(0); + + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await clear.click(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(searchbox).toHaveValue(""); + await expect(searchbox).toBeFocused(); + await expect(clear).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + test("replaces history while typing a search", async ({ page }) => { await page.goto("/"); await page.goto("/plugins/all"); diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index a6bd398a19..042e5e81e5 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -1,18 +1,20 @@ import { useNavigate } from "@tanstack/react-router"; -import { Search } from "lucide-react"; -import { type ChangeEvent, type FormEvent, useCallback, useState } from "react"; +import { Search, X } from "lucide-react"; +import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react"; import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links"; +const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground"; + /** * Search box for the plugin pages. Typing replaces the URL with the browse page for the term, * keeping `scope`'s category and sort. Without JavaScript the form submits the same URL. */ export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) { const navigate = useNavigate(); + const input = useRef(null); const [term, setTerm] = useState(scope.q ?? ""); - const handleChange = useCallback( - (event: ChangeEvent) => { - const next = event.target.value; + const search = useCallback( + (next: string) => { setTerm(next); void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), @@ -21,6 +23,14 @@ export function PluginSearch({ scope, className }: { scope: BrowseQuery; classNa }, [navigate, scope], ); + const handleChange = useCallback( + (event: ChangeEvent) => search(event.target.value), + [search], + ); + const handleClear = useCallback(() => { + search(""); + input.current?.focus(); + }, [search]); const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []); return (
)} -
+ + ); } From bc6ddbfa978074ec8aaa0f94debf528652b60e5f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Oct 2026 17:47:04 +0100 Subject: [PATCH 065/281] fix(muse): restore earlier messages after daemon restart (#6159) MSP view cursors request only a suffix. A fresh provider session rebuilds an empty timeline, so it must resume without a cursor to recover earlier messages. Keep gap recovery cursors in memory and omit them from durable session state. --- plugins/muse-provider/server/session.ts | 11 +-- plugins/muse-provider/server/wire.ts | 1 - plugins/muse-provider/test/fake-muse.mjs | 19 +++++ plugins/muse-provider/test/provider.test.ts | 79 +++++++++++++++++---- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/plugins/muse-provider/server/session.ts b/plugins/muse-provider/server/session.ts index 7d367fd1ac..31066c5b04 100644 --- a/plugins/muse-provider/server/session.ts +++ b/plugins/muse-provider/server/session.ts @@ -67,7 +67,6 @@ export class Session { private readonly host: MspConnection; private liveState: Live | undefined; private providerId: string | undefined; - private cursor: string | undefined; private config: ProviderSessionConfig; private catalog: ProviderCatalog = { models: [], modes }; private readonly approvals = new Map(); @@ -110,9 +109,11 @@ export class Session { if (input.persistence.version !== 1) throw new MuseError("invalidPersistence", "Unsupported Muse persistence version"); const saved = persistenceSchema.parse(input.persistence.data); + // A fresh timeline needs history. MSP cursor resumes return only the suffix, + // so even legacy persistence cursors cannot restore this session's projection. response = await this.host.command( "session/resume", - { sessionId: saved.sessionId, cursor: saved.cursor, config: { mcpServers } }, + { sessionId: saved.sessionId, config: { mcpServers } }, sessionSchema, ); this.config = { @@ -168,7 +169,6 @@ export class Session { ), }; this.providerId = response.session.providerId ?? undefined; - this.cursor = response.viewCursor; this.config = { ...this.config, model: response.session.modelId ?? this.config.model, @@ -411,10 +411,6 @@ export class Session { } await this.processNotification(notification); this.live().recovery.activity(envelope.viewCursor, this.activeTurnIds().length > 0); - if (envelope.viewCursor) { - this.cursor = envelope.viewCursor; - this.publishPersistence(); - } } private async processNotification(notification: Notification): Promise { switch (notification.method) { @@ -597,7 +593,6 @@ export class Session { version: 1, data: { sessionId: this.live().nativeId, - ...(this.cursor ? { cursor: this.cursor } : {}), ...(this.config.model ? { model: this.config.model } : {}), ...(this.config.thinkingOption ? { thinkingOption: this.config.thinkingOption } : {}), }, diff --git a/plugins/muse-provider/server/wire.ts b/plugins/muse-provider/server/wire.ts index 3513cabfd1..45aac6fe4f 100644 --- a/plugins/muse-provider/server/wire.ts +++ b/plugins/muse-provider/server/wire.ts @@ -143,7 +143,6 @@ export const sessionSchema = z.object({ }); export const persistenceSchema = z.object({ sessionId: z.string(), - cursor: z.string().optional(), model: z.string().optional(), thinkingOption: effortSchema.optional(), }); diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index 6dec29f8e5..16cc821b79 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -80,6 +80,22 @@ function receiveFrame(line) { } else for (const row of messages) emitFixtureMessage(row.msg, recorded, frame); } function controlResponse(frame) { + if (frame.method === "session/resume" && process.env.MUSE_TEST_HISTORY) { + const result = responseFor(readFixture("resume-without-cursor"), "session/resume"); + result.session.sessionId = frame.params.sessionId; + result.history = frame.params.cursor + ? { mode: "none", noneReason: "cursorSuffix", items: null, snapshot: null } + : { + mode: "inline", + items: readFileSync(process.env.MUSE_TEST_HISTORY, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + snapshot: null, + }; + respond(frame, result); + return true; + } if (frame.method === "initialized") return true; if (frame.method === "initialize" && process.env.MUSE_TEST_EXIT) { process.stderr.write("diagnostic tail, not protocol\n"); @@ -438,6 +454,9 @@ function replace(value) { return value; } function send(frame) { + if (frame.method === "item/completed" && process.env.MUSE_TEST_HISTORY) { + appendFileSync(process.env.MUSE_TEST_HISTORY, JSON.stringify(frame.params.item) + "\n"); + } process.stdout.write(JSON.stringify(frame) + "\n"); } function respond(frame, result) { diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 800a010da9..447988e31b 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -221,9 +221,9 @@ test("text and reasoning stream, completion overwrites deltas, user echo retains { type: "text", text: "hello" }, ], }); - expect(h.events.filter((event) => event.type === "session.persistence").length).toBeGreaterThan( - 1, - ); + expect(h.events.findLast((event) => event.type === "session.persistence")).toMatchObject({ + persistence: { version: 1, data: { model: "meta/muse-spark-1.3", thinkingOption: "high" } }, + }); }); test("tool calls classify read, shell, and edit with fetched JSON patch converted to unified diff", async () => { const h = await harness("tools-edit"); @@ -327,23 +327,69 @@ test("interrupt awaits cancelled terminal and reconciles the tool", async () => ), ).toBeGreaterThan(terminalIndex); }); +test("a fresh connection restores earlier messages from persisted session state", async () => { + const original = await harness(); + const historyPath = path.join(original.root, "native-history.ndjson"); + original.launch.env.MUSE_TEST_HISTORY = historyPath; + await original.open(); + await original.prompt(); + await original.wait((event) => event.type === "session.turn" && event.state === "completed"); + const messages = [ + ...new Map( + original.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message") + .map((item) => [item.id, item] as const), + ).values(), + ]; + expect(messages.map((item) => item.type)).toEqual(["user_message", "assistant_message"]); + const saved = original.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected session persistence"); + const persistencePath = path.join(original.root, "persistence.json"); + // Older daemon records carry a view cursor even though their timeline is not durable. + await writeFile( + persistencePath, + JSON.stringify({ + ...saved.persistence, + data: { ...saved.persistence.data, cursor: saved.persistence.data.cursor ?? "v:old:7" }, + }), + ); + await original.connection.close(); + const reopened = await harness("text-reasoning", { MUSE_TEST_HISTORY: historyPath }); + expect(await reopened.open(JSON.parse(await readFile(persistencePath, "utf8")))).toMatchObject({ + type: "session.ready", + }); + const restored = reopened.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message"); + expect(restored.map((item) => ({ type: item.type, text: item.text }))).toEqual( + messages.map((item) => ({ type: item.type, text: item.text })), + ); + const reopenedState = reopened.events.findLast((event) => event.type === "session.persistence"); + expect(reopenedState?.persistence.data).not.toHaveProperty("cursor"); + expect(reopened.events.filter((event) => event.type === "session.turn")).toEqual([]); + await reopened.prompt(); + await reopened.wait((event) => event.type === "session.turn" && event.state === "completed"); +}); + for (const cursor of [undefined, "v:old:7"]) { - test(`resume ${cursor ? "with cursor emits suffix and ignores old terminal" : "without cursor replays inline history"}`, async () => { - const h = await harness(cursor ? "resume-with-cursor" : "resume-without-cursor"); + test(`resume ${cursor ? "with a legacy persistence cursor" : "without a persistence cursor"} replays inline history`, async () => { + const h = await harness("resume-without-cursor"); await h.open({ version: 1, data: { sessionId: "saved-session", ...(cursor ? { cursor } : {}) }, }); expect(h.events.filter((event) => event.type === "session.turn")).toEqual([]); const history = h.events.filter((event) => event.type === "timeline.item"); - if (!cursor) - expect(history).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), - }), - ]), - ); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), + }), + ]), + ); await h.prompt(); await h.wait((event) => event.type === "session.turn" && event.state === "completed"); expect( @@ -351,7 +397,10 @@ for (const cursor of [undefined, "v:old:7"]) { ).toHaveLength(1); expect( (await h.recorded()).find((frame) => frame.method === "session/resume").params, - ).toMatchObject({ sessionId: "saved-session", ...(cursor ? { cursor } : {}) }); + ).toMatchObject({ sessionId: "saved-session" }); + expect( + (await h.recorded()).find((frame) => frame.method === "session/resume").params, + ).not.toHaveProperty("cursor"); }); } test("steer sends ifBusy steer and joins the running turn", async () => { @@ -862,7 +911,7 @@ test("fingerprint mismatch is logged but does not prevent opening", async () => }); for (const restoring of [false, true]) { test(`MCP servers map to MSP stdio and streamableHttp on ${restoring ? "resume" : "start"}`, async () => { - const h = await harness(restoring ? "resume-with-cursor" : "text-reasoning"); + const h = await harness(restoring ? "resume-without-cursor" : "text-reasoning"); await h.send({ type: "session.open", sessionId: "paseo-session", From 18412604dd2d226c163f64b229edd7394523ccad Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:21:46 +0200 Subject: [PATCH 066/281] fix(app): copy only the code when a selection touches the block beside it (#6138) * fix(app): copy a code block without its fence when the drag starts beside it A drag that starts or ends in the gap beside a code block anchors the selection at the edge of the neighbouring block. Only the code is highlighted, but the range crossed the block boundary, so the copy took the Markdown path and wrapped the code in its fence. A selection now crosses a code block only when it selects content outside it. Message rows the range touches without selecting anything are dropped, and a selection whose content all lies inside one code block copies as that code. * fix(app): keep a selected image when copying it beside a code block An image selected next to a code block has no text, so the selection counted it as empty and copied only the code. Count images as selected content so the copy keeps the image and the fence, as it did before. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/assistant-selection-copy.spec.ts | 48 +++++++++++++ .../content.browser.test.ts | 70 ++++++++++++++++++- .../assistant-selection-copy/content.web.ts | 48 +++++++++++-- 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 5d4838f133..e2d9e3c02b 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -120,6 +120,41 @@ async function selectAssistantElement(page: Page, selector: string): Promise { + await assistantMessageBlocks(page).evaluateAll( + (blocks, selected) => { + let startNode: Text | null = null; + for (const block of blocks) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (walker.currentNode.textContent?.endsWith(selected.startText)) { + startNode = walker.currentNode as Text; + } + } + } + const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean); + if (!startNode || !end) { + throw new Error(`Could not find ${selected.startText} — ${selected.selector}`); + } + const range = document.createRange(); + range.setStart(startNode, startNode.length); + range.setEnd(end, end.childNodes.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, + { startText, selector }, + ); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -565,6 +600,19 @@ test("copying an assistant selection preserves Markdown structure and links", as const completeCodeClipboard = await readRichClipboard(page); expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;'); + // Touching the block before it without selecting any of its text is still a + // selection inside the code. + await selectAssistantElementFromEndOf( + page, + "not a generated link.", + '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]', + ); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + 'const answer = "yes";\n return answer;', + ); + // Crossing the block boundary expresses an intent to carry its Markdown structure. await selectAssistantAcrossCodeLines(page, "const", "After code."); await copySelection(page); diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 04aa28bd99..00a64406ea 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -405,10 +405,19 @@ describe("assistant selection copy ranges", () => { * Turndown, so those cases need a fixture shaped like the real thing. */ function highlightedFixture(language: string | null): string { + return [ + '
', + '
Before the block.
', + highlightedCodeBlock(language), + '
After the block.
', + "
", + ].join(""); +} + +function highlightedCodeBlock(language: string | null): string { const languageAttribute = language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`; return [ - '
', `
`, '', "const answer = 1;", @@ -419,8 +428,6 @@ function highlightedFixture(language: string | null): string { "", '
Copy
', "
", - '
After the block.
', - "
", ].join(""); } @@ -598,6 +605,63 @@ describe("assistant selection copy inside highlighted code", () => { ); }); + // A drag that starts or ends in the gap beside a block anchors the selection at the + // edge of the neighbouring block. Nothing outside the code is highlighted or selected. + it("copies code without a fence when the selection only touches the end of the block before it", () => { + const message = mountHighlighted(); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the start of the block after it", () => { + const message = mountHighlighted(); + + const content = copyBetween( + [tokenText(message, "const"), 0], + [tokenText(message, "After the block."), 0], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the row before the block", () => { + const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]); + const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!; + codeRow.innerHTML = highlightedCodeBlock("typescript"); + const before = tokenText(transcript, "Run this:"); + + const content = copyBetween( + [before, before.length], + [tokenText(transcript, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("keeps an image selected beside the code in the copy", () => { + const message = mountHighlighted(); + const image = document.createElement("div"); + image.setAttribute("data-paseo-markdown-tag", "p"); + image.innerHTML = '
chart
'; + message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe( + "![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```", + ); + }); + it("retains the fence when a complete code block is selected across its boundary", () => { const message = mountHighlighted(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index d019a23012..aa6e6cd91b 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -67,8 +67,8 @@ export function createAssistantSelectionClipboardContent( } const range = selection.getRangeAt(0); - const parts = selectedMessageParts(range); - if (!parts) { + const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range)); + if (!parts?.length) { return null; } @@ -103,10 +103,12 @@ export function createAssistantSelectionClipboardContent( * * A selection contained inside code always copies as code, even when it contains every * character. A selection that crosses the code boundary stays on the Markdown path so - * a complete block retains its fence. + * a complete block retains its fence. Crossing means selecting content outside the code: + * a drag that starts or ends in the gap beside a block anchors at the edge of the + * neighbouring block, which selects nothing there. */ function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null { - const region = closestCodeRegion(range.commonAncestorContainer, message); + const region = selectedCodeRegion(range, message); if (!region) { return null; } @@ -130,6 +132,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb }); } +function selectedCodeRegion(range: Range, message: Element): Element | null { + const edgeRegions = [range.startContainer, range.endContainer].map((node) => + closestCodeRegion(node, message), + ); + return ( + edgeRegions.find( + // Measure against the whole block, so its own hover Copy button is not outside it. + (edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge), + ) ?? null + ); +} + function closestCodeRegion(node: Node, message: Element): Element | null { const element = node instanceof Element ? node : node.parentElement; const region = element?.closest(CODE_REGION_SELECTOR) ?? null; @@ -384,6 +398,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored = return true; } +function selectsContent(range: Range): boolean { + return hasMarkdownContent(range.cloneContents(), true); +} + +function selectsNothingOutside(range: Range, element: Element): boolean { + const contents = document.createRange(); + contents.selectNodeContents(element); + + if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) { + const before = range.cloneRange(); + before.setEnd(contents.startContainer, contents.startOffset); + if (selectsContent(before)) { + return false; + } + } + if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) { + const after = range.cloneRange(); + after.setStart(contents.endContainer, contents.endOffset); + if (selectsContent(after)) { + return false; + } + } + return true; +} + function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean { if (!includeIgnored) { for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { @@ -395,6 +434,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean) } const visibleVoidSelector = ["br", "hr"] .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`) + .concat("img") .join(","); return Boolean(fragment.querySelector(visibleVoidSelector)); } From 3e2d77723e67696f079ac45397090aa9c3739d22 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Oct 2026 18:41:54 +0100 Subject: [PATCH 067/281] fix(usage): scope Muse and agent logins, show every failed host login (#6156) * fix(muse): show usage only for the agent account Usage discovery ignored session scope and exposed all remembered accounts to unrelated agents. Resolve session accounts from their own launch environment while preserving global discovery. * test(muse): own the usage scope regression in the plugin suite * fix(usage): scope agent reports to their own logins Account grouping also merged fetch inputs and cache entries across agent and host queries, hiding the agent login failure behind another harness. Preserve scoped login sets, collect every host failure with source-owned harness labels, and keep refresh results within their requesting view. * test(usage): expect harness labels in discovered accounts --- docs/plugins.md | 4 +- .../e2e/browser/context-window-meter.spec.ts | 8 +- packages/app/e2e/browser/usage-screen.spec.ts | 65 ++++++- .../app/e2e/support/helpers/context-window.ts | 10 +- .../app/e2e/support/helpers/usage-login.ts | 17 +- packages/app/src/usage/agent-usage.tsx | 10 +- packages/app/src/usage/card.tsx | 25 ++- packages/app/src/usage/queries.ts | 22 ++- packages/plugin/src/server/usage.ts | 2 + packages/protocol/src/messages.ts | 11 ++ .../protocol/src/messages.wire-compat.test.ts | 47 +++++ .../plugins/usage-sources/index.test.ts | 167 +++++++++++++++++- .../src/server/plugins/usage-sources/index.ts | 83 ++++++--- .../server/keychain.test.ts | 1 + .../claude-usage-source/server/stores.test.ts | 1 + .../claude-usage-source/server/usage.test.ts | 18 +- plugins/claude-usage-source/server/usage.ts | 7 +- .../codex-usage-source/server/usage.test.ts | 53 ++++++ plugins/codex-usage-source/server/usage.ts | 8 +- plugins/muse-provider/server/usage.ts | 27 ++- plugins/muse-provider/test/provider.test.ts | 51 +++++- public-docs/plugins/reference.md | 42 +++-- 22 files changed, 592 insertions(+), 87 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 401e33b94b..387f24f46b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -488,9 +488,9 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. -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. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. +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. ## Contribute sidebar items diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 9bd5193173..80a43d6aa2 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -273,7 +273,7 @@ for (const theme of ["light", "dark"] as const) { const card = contextWindowDetails(page); await refreshUsageCard(card, "Claude"); await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); }); await test.step("after a resume under another login, only that login shows", async () => { @@ -321,7 +321,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(5); + expect(usage.agentRequests()).toHaveLength(6); }); }); @@ -351,7 +351,7 @@ for (const theme of ["light", "dark"] as const) { const sheet = contextWindowDetails(page); await refreshUsageCard(sheet, "Claude"); await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); await shot("problem"); }); @@ -365,7 +365,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(2); + expect(usage.agentRequests()).toHaveLength(3); }); }); } diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index e457c43d22..1dfe89ae19 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -322,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag await gotoAppShell(page); await openUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); await qaScreenshot(page, "usage-expired-login"); await fixture.setReport(loginWindows); await refreshLoginUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); @@ -474,3 +474,64 @@ async function captureSettingsState( await qaScreenshot(page, name); await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); } + +test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({ + page, +}) => { + const fixture = await installLoginUsage([ + { harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "opencode" }, + }, + }, + { + harness: "Pi", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } }, + }, + { + harness: "OMP", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 403, refreshedBy: "omp" }, + }, + }, + ]); + try { + await gotoAppShell(page); + await openUsage(page); + await expectLoginErrors(page, [ + "Codex: Usage API returned 500. Try again.", + "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", + "Pi: No active plan", + "OMP: Login rejected (HTTP 403). Run omp to refresh it.", + ]); + await fixture.setReport([ + { + harness: "Codex", + report: { status: "error", error: "Usage API returned 500. Try again." }, + }, + { + harness: "OpenCode", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + ]); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + } finally { + await fixture.cleanup(); + } +}); + +async function expectLoginErrors(page: Page, lines: string[]) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n")); +} + +async function expectLoginUsageOnly(page: Page, percentage: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveCount(0); +} diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 82f2be6da5..21341c7766 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -134,8 +134,8 @@ export interface AgentUsageScript { stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; - /** Each report Refresh, by report ID. */ - refreshedReports(): string[][]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; } /** @@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), - refreshedReports: () => - usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), }; } diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 234ec2bf1b..985e7bd7ac 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; /** A real store-backed plugin on the worker's isolated daemon. */ -export async function installLoginUsage(initialReport: UsageReport) { +type LoginReports = Array<{ harness: string; report: UsageReport }>; + +export async function installLoginUsage(initialReport: UsageReport | LoginReports) { const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const store = path.join(directory, "report.json"); - const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const setReport = (report: UsageReport | LoginReports) => + writeFile(store, JSON.stringify(report)); const cleanup = async () => { try { await client.removePlugin("login-journey"); @@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) { import {readFile} from "node:fs/promises"; import {z} from "zod"; export default function contribute(server) { - server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), - discover: async () => [{key: "account", input: {}}], - fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + const read = async () => { + const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")); + return Array.isArray(value) ? value : [{harness: "Claude", report: value}]; + }; + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}), + discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})), + fetch: async ({index}) => (await read())[index].report, }); return () => {}; }`, diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index a31ae89478..0199c08ff2 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -27,7 +27,12 @@ export function AgentUsage({ <> {view.kind === "ready" ? ( - + ) : ( {view.kind === "loading" ? usageCopy.loading : view.message} @@ -39,10 +44,12 @@ export function AgentUsage({ function AgentUsageCards({ serverId, + agentId, reports, refreshable, }: { serverId: string; + agentId: string; reports: readonly UsageReportEntry[]; refreshable: boolean; }) { @@ -51,6 +58,7 @@ function AgentUsageCards({ `${login.harness}: ${reportContent(login.report).message}`, + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + return [ + entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), + ]; +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); export function UsageCard({ serverId, + agentId, entry, display, pinnable, @@ -52,6 +65,7 @@ export function UsageCard({ compact = false, }: { serverId: string; + agentId?: string; entry: UsageReportEntry; display: UsageDisplay; /** Whether each window row pins the window to the sidebar. */ @@ -61,13 +75,14 @@ export function UsageCard({ compact?: boolean; }) { const isCompact = useIsCompactFormFactor(); - const { refresh, refreshState } = useReportRefresh(serverId, entry.id); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details, message } = reportContent(usage); + const { windows, balances, details } = reportContent(usage); + const messages = reportMessages(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -110,9 +125,9 @@ export function UsageCard({ ) : null} - {message ? ( - - {message} + {messages.length > 0 ? ( + + {messages.join("\n")} ) : null} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index f054d6d082..7b6f210543 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -98,10 +98,14 @@ async function getReport( serverId: string, reportId: string, forceRefresh = false, + agentId?: string, ): Promise { return ( - (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) - .reports[0] ?? null + ( + await requireClient(serverId).listUsageReports( + agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh }, + ) + ).reports.find((report) => report.id === reportId) ?? null ); } @@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] { } /** - * Forces the source to fetch one report, and only that report. The result replaces the report - * in every list of its host that holds it, the host's and each agent's, so every surface showing - * it moves together; until then the previous report stays on screen. + * Refreshes a host report, or the agent's session reports. The result replaces the card + * in the requesting view. Host and agent views can use different logins of the same account. */ export function useReportRefresh( serverId: string, reportId: string, + agentId?: string, ): { refresh: () => void; refreshState: UsageRefresh } { const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: () => getReport(serverId, reportId, true), + mutationFn: () => getReport(serverId, reportId, true, agentId), onSuccess: (report) => { - queryClient.setQueriesData( - { queryKey: hostUsageQueryKey(serverId) }, + queryClient.setQueryData( + agentId === undefined + ? usageReportsQueryKey(serverId) + : agentUsageQueryKey(serverId, agentId), (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 4ad2661d67..fa23ca9c91 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -58,6 +58,8 @@ export interface UsageAccount { /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ key: string; label?: string; + /** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */ + harness?: string; /** Store locator, opaque to the daemon. */ input: JsonValue; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0214502263..41b9ca3f74 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + loginErrors: z + .array( + z.object({ + harness: z.string(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + ) + .optional(), }); export const UsageListReportsUpdateMessageSchema = z.object({ type: z.literal("usage.list_reports.update"), diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index fe2a79916f..2298d680a4 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance", legacySnapshot.parse(failed), ); }); + +test("usage login errors are additive and older reports still parse", () => { + const entry = { + id: "codex:account", + account: {}, + fetchedAt: "2026-10-05T00:00:00.000Z", + sourceId: "codex", + sourceLabel: "Codex", + report: { status: "error", error: "Usage API returned 500" }, + }; + const legacy = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ + requestId: z.string(), + report: z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }), + z.object({ status: z.literal("unavailable"), problem: z.unknown() }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + }), + }); + const oldMessage = { + type: "usage.list_reports.update", + payload: { requestId: "usage", report: entry }, + }; + const newMessage = { + ...oldMessage, + payload: { + ...oldMessage.payload, + report: { + ...entry, + loginErrors: [{ harness: "Codex", report: entry.report }], + }, + }, + }; + expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage); + expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); + expect(legacy.parse(newMessage)).toEqual(oldMessage); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 37ab021831..eeea242788 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -190,7 +190,7 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name ).toEqual(["claude (work)", "claude (personal)", "claude"]); }); -test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => { +test("duplicate logins prefer discovery order across unavailable, error and thrown fetches", async () => { for (const failure of ["unavailable", "error", "throw"]) { const registry = new UsageSourceRegistry(); const calls: number[] = []; @@ -216,7 +216,7 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro const reports = await registry.listReports(); expect(reports).toHaveLength(1); expect(reports[0]?.report.status).toBe("available"); - expect(calls).toEqual([0, 1]); + expect(calls).toEqual([0, 1, 2]); } }); @@ -338,7 +338,7 @@ test("session discovery reuses accounts, separates launches, and retains live ag await registry.listReports({ agentId: "one" }); await registry.listReports({ agentId: "two" }); expect(discoveries).toEqual(["/one", "/two"]); - expect(fetches).toBe(1); + expect(fetches).toBe(2); expect((await registry.listReports()).map((r) => r.id)).toEqual([ "claude:default", "claude:same", @@ -395,3 +395,164 @@ test("reports arrive independently and a hung fetch settles as an error before c entries[0]?.report, ); }); + +test("#6155: agent reports use only their session login despite host and sibling discoveries", async () => { + const sessions = new Map([ + ["codex-agent", { provider: "codex", env: {}, sessionKey: "codex-launch" }], + ["opencode-agent", { provider: "opencode", env: {}, sessionKey: "opencode-launch" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const codexError = { status: "error", error: "Codex usage API returned 500" }; + const expired = { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["codex", "opencode"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: { harness }, + })), + fetch: async (input) => + (input as { harness: string }).harness === "codex" ? codexError : expired, + }); + await registry.listReports(); + await registry.listReports({ agentId: "opencode-agent" }); + expect((await registry.listReports({ agentId: "codex-agent" }))[0]?.report).toEqual(codexError); +}); + +test("all failed host logins retain each source-supplied harness and remedy", async () => { + const registry = new UsageSourceRegistry(); + const failures = [ + { status: "error", error: "Codex usage API returned 500" }, + { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }, + { status: "unavailable", problem: { kind: "rejected", status: 401, refreshedBy: "pi" } }, + { status: "unavailable", problem: { kind: "rejected", status: 403, refreshedBy: "omp" } }, + ]; + const harnesses = ["Codex", "OpenCode", "Pi", "OMP"]; + registry.register( + source({ + id: "codex", + discover: async () => harnesses.map((harness, input) => ({ key: "same", harness, input })), + fetch: async (input) => failures[Number(input)], + }), + ); + const [entry] = await registry.listReports(); + expect(entry?.loginErrors).toEqual( + harnesses.map((harness, index) => ({ harness, report: failures[index] })), + ); +}); + +test("a working agent login never uses another harness's failure or host fallback cache", async () => { + const sessions = new Map([ + ["working", { provider: "codex", env: {}, sessionKey: "one" }], + ["broken", { provider: "opencode", env: {}, sessionKey: "two" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const available = { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], + }; + const error = { status: "error", error: "OpenCode login rejected. Run opencode to refresh it." }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["opencode", "codex"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: harness, + })), + fetch: async (input) => (input === "codex" ? available : error), + }); + const [host] = await registry.listReports(); + expect(host?.report).toEqual(available); + expect(host?.loginErrors).toBeUndefined(); + expect((await registry.listReports({ agentId: "working" }))[0]?.report).toEqual(available); + expect((await registry.listReports({ agentId: "broken" }))[0]?.report).toEqual(error); + expect( + (await registry.listReports({ agentId: "broken", forceRefresh: true }))[0]?.report, + ).toEqual(error); + expect( + (await registry.listReports({ reportIds: ["codex:same"], forceRefresh: true }))[0]?.report, + ).toEqual(available); +}); + +test("concurrent host and agent requests never share a different login's pending fetch", async () => { + let finish!: (report: unknown) => void; + const slow = new Promise((resolve) => { + finish = resolve; + }); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [{ key: "same", harness: "Codex", input: scope.kind }], + fetch: async (input) => + input === "global" ? slow : { status: "error", error: "Session login failed" }, + }); + const host = registry.listReports(); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ status: "error", error: "Session login failed" }); + finish({ status: "available", windows: [] }); + expect((await host)[0]?.report.status).toBe("available"); +}); + +test("duplicate logins across scopes appear only once in the host's errors", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [ + { + key: "same", + harness: "Codex", + input: + scope.kind === "global" + ? { path: "/auth", kind: "file" } + : { kind: "file", path: "/auth" }, + }, + ], + fetch: async () => ({ status: "error", error: "Login failed" }), + }); + await registry.listReports({ agentId: "agent" }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Login failed" } }, + ]); +}); + +test("a hung login records its own timeout and still tries the next login", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + registry.register({ + id: "codex", + label: "Codex", + discover: async () => [ + { key: "same", harness: "Codex", input: 0 }, + { key: "same", harness: "OpenCode", input: 1 }, + ], + fetch: async (input) => + input === 0 ? new Promise(() => {}) : { status: "error", error: "Login rejected" }, + }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Usage fetch timed out" } }, + { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index e0fa300dcf..c0c9d781e5 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { z } from "zod"; @@ -18,9 +19,14 @@ export interface UsageSource { fetch(input: unknown): Promise; } +interface Login { + input: unknown; + harness: string; +} + interface KnownReport { source: UsageSource; - inputs: [unknown, ...unknown[]]; + logins: [Login, ...Login[]]; label?: string; } @@ -75,6 +81,7 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } async listReports(options: ListUsageReportsOptions = {}): Promise { @@ -82,8 +89,10 @@ export class UsageSourceRegistry { throw new Error("agentId and reportIds cannot be combined"); this.pruneAgents(); let ids: string[]; + let reports = this.known; if (options.agentId !== undefined) { ids = await this.discoverAgent(options.agentId); + reports = this.byAgent.get(options.agentId)?.reports ?? new Map(); } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { @@ -93,7 +102,7 @@ export class UsageSourceRegistry { } return Promise.all( [...new Set(ids)].flatMap((id) => { - const known = this.known.get(id); + const known = reports.get(id); if (!known) return []; return [ this.fetchId(id, known, options.forceRefresh).then((entry) => { @@ -149,8 +158,14 @@ export class UsageSourceRegistry { for (const [id, report] of reports) { if (this.sources.get(report.source.id) !== report.source) continue; const known = this.known.get(id); - if (known) known.inputs.push(...report.inputs); - else this.known.set(id, { ...report, inputs: [...report.inputs] }); + if (!known) { + this.known.set(id, { ...report, logins: [...report.logins] }); + continue; + } + for (const login of report.logins) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } } } } @@ -165,6 +180,7 @@ export class UsageSourceRegistry { z.object({ key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), label: z.string().optional(), + harness: z.string().min(1).optional(), input: z.json(), }), ) @@ -172,8 +188,12 @@ export class UsageSourceRegistry { for (const account of accounts) { const id = `${source.id}:${account.key}`; const known = reports.get(id); - if (known) known.inputs.push(account.input); - else reports.set(id, { source, inputs: [account.input], label: account.label }); + // COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0. + const login = { input: account.input, harness: account.harness ?? source.label }; + if (known) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } else reports.set(id, { source, logins: [login], label: account.label }); } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); @@ -214,10 +234,14 @@ export class UsageSourceRegistry { } private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { - const cached = this.cache.get(id); + // Account identity groups cards; the ordered login set identifies a fetch result. + const cacheKey = `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; + const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); - const pending = this.pending.get(id); + const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { const entry: UsageReportEntry = { @@ -227,14 +251,14 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchBeforeDeadline(known), + ...(await this.fetchWithFallback(known)), }; - this.writeCache(id, entry); + if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); return entry; })(); - this.pending.set(id, request); + this.pending.set(cacheKey, request); void request.finally(() => { - if (this.pending.get(id) === request) this.pending.delete(id); + if (this.pending.get(cacheKey) === request) this.pending.delete(cacheKey); }); return request; } @@ -247,7 +271,7 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private async fetchBeforeDeadline(known: KnownReport): Promise { + private async fetchBeforeDeadline(source: UsageSource, input: unknown): Promise { let timer: ReturnType | undefined; const deadline = new Promise((resolve) => { timer = setTimeout( @@ -256,19 +280,29 @@ export class UsageSourceRegistry { ); }); try { - return await Promise.race([this.fetchWithFallback(known), deadline]); + return await Promise.race([this.fetchLogin(source, input), deadline]); } finally { clearTimeout(timer); } } - private async fetchWithFallback(known: KnownReport): Promise { - let report = await this.fetchLogin(known.source, known.inputs[0]); - for (const input of known.inputs.slice(1)) { - if (report.status === "available") break; - report = await this.fetchLogin(known.source, input); + private async fetchWithFallback( + known: KnownReport, + ): Promise> { + // Start each login within the same deadline window, then prefer discovery order. + // Sequential deadlines can exceed the client's RPC timeout before all errors arrive. + const attempts = known.logins.map((login) => + this.fetchBeforeDeadline(known.source, login.input), + ); + const loginErrors: NonNullable = []; + for (const [index, login] of known.logins.entries()) { + const report = await attempts[index]!; + if (report.status === "available") return { report }; + loginErrors.push({ harness: login.harness, report }); } - return report; + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once app floor >= v0.11.0. + // Keep the final single report so older apps still parse and display failed accounts. + return { report: loginErrors[loginErrors.length - 1]!.report, loginErrors }; } private async fetchLogin(source: UsageSource, input: unknown): Promise { @@ -303,3 +337,12 @@ function legacyError(report: UsageReport, now: number): string | null { if (report.status === "unavailable") return legacyProblem(report.problem, now); return null; } + +// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +function loginKey(login: Login): string { + return JSON.stringify(login, (_key, value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); +} diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index d85401037a..dd6c58f77a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -141,6 +141,7 @@ describe("Claude credential routes", () => { expect(accounts).toEqual([ { key: "fixture-account.fixture-org", + harness: "Claude", input: { route: { store: "claude", path: join(dir, ".credentials.json") } }, }, ]); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 75248116cc..c830fca9cc 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -237,6 +237,7 @@ test("keychain identity belongs to its token even when Claude Code metadata name ); expect(identity).toEqual({ key: "pi-account.pi-org", + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, }, diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index be2fd45b7f..29e78a54e9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -915,6 +915,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc expect(accounts).toEqual([ { key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, }, @@ -960,7 +961,11 @@ it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", const api: typeof fetch = async () => new Response(null, { status }); const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", + input: credentialInput(lookup.claudeHome), + }, ]); let fetches = 0; expect( @@ -992,7 +997,9 @@ it.each([ return new Response(null, { status: 500 }); }; const accounts = await discover({ kind: "global" }, lookup, api); - expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + expect(accounts).toEqual([ + { key: hashAccountKey(JSON.stringify(input.route)), harness: "Claude", input }, + ]); await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); }, ); @@ -1071,7 +1078,12 @@ it("session discovery reads only its Claude home and excludes foreign sessions", const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} }; const accounts = await discover(scope, lookup, api); expect(accounts).toEqual([ - { key: "session-account.org", label: "session@example.test", input: credentialInput(home) }, + { + key: "session-account.org", + label: "session@example.test", + harness: "Claude", + input: credentialInput(home), + }, ]); await discover(scope, lookup, api); expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 72a82c1c8b..9efade2062 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -380,7 +380,10 @@ export async function discover( for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); if (!credentials) continue; - const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input }; + const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[ + input.route.store + ]; + const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input }; if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { accounts.push(fallback); continue; @@ -391,7 +394,7 @@ export async function discover( fetchApi, lookup.now ?? Date.now, ); - accounts.push("status" in profile ? fallback : { ...profile, input }); + accounts.push("status" in profile ? fallback : { ...profile, harness, input }); } catch { // Keep the login visible; fetching usage reports the vendor failure. accounts.push(fallback); diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 8222aae315..4cdefa2bcd 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -412,3 +413,55 @@ test("session discovery isolates CODEX_HOME and excludes foreign routes", async await rm(home, { recursive: true, force: true }); } }); + +test("#6155: real Codex and OpenCode stores share an account but keep agent failures separate", async () => { + await writeAuth("fixture-codex", "same-account"); + const data = join(fixtureHome, "data"); + await mkdir(join(data, "opencode"), { recursive: true }); + await writeFile( + join(data, "opencode", "auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-opencode", accountId: "same-account", expires: 0 }, + }), + ); + const env = { HOME: fixtureHome, CODEX_HOME: fixtureHome, XDG_DATA_HOME: data }; + const lookup = { home: fixtureHome, env, platform: "linux" as const, now: () => 1_000 }; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => id === "agent", + usageSession: () => ({ provider: "codex", env, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: (scope) => discover(scope, lookup), + fetch: (input) => + fetchUsage(inputSchema.parse(input), async () => new Response(null, { status: 401 }), lookup), + }); + const [host] = await registry.listReports(); + expect(host?.loginErrors).toEqual([ + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }, + }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: "1970-01-01T00:00:00.000Z", + refreshedBy: "opencode", + }, + }, + }, + ]); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }); + expect(agent?.loginErrors).toEqual([host!.loginErrors![0]]); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index cf5207ff22..0b211726c6 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -72,7 +72,8 @@ export async function discover( const present: UsageAccount[] = []; for (const input of candidates) { const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + if (auth) + present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input }); } return present; } @@ -294,3 +295,8 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab claimString(id?.["email"]); return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) }; } + +function harnessLabel(input: CodexUsageInput): string { + const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" }; + return labels[input.route.store]; +} diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a8c77428d6..e3915c6ddd 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -16,6 +16,13 @@ import { usageSchema } from "./wire.js"; const inputSchema = z.object({ account: z.string() }).strict(); type Observation = z.infer; + +function accountKey(env: ProviderLaunch["env"]): string { + // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. + const configHome = env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? homedir(), ".config"); + return hashAccountKey(path.resolve(configHome, "muse")); +} + export class Usage { private readonly launches = new Map(); private readonly sessions = new Map< @@ -23,10 +30,7 @@ export class Usage { { account: string; read: () => Promise } >(); remember(launch: ProviderLaunch): { account: string } { - // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. - const configHome = - launch.env.XDG_CONFIG_HOME ?? path.join(launch.env.HOME ?? homedir(), ".config"); - const key = hashAccountKey(path.resolve(configHome, "muse")); + const key = accountKey(launch.env); this.launches.set(key, launch); return { account: key }; } @@ -60,12 +64,21 @@ export class Usage { label: "Muse Code", icon: "icon.svg", input: inputSchema, - discover: async () => - [...this.launches.keys()].map((account) => ({ + discover: async (scope) => { + let accounts: string[]; + if (scope.kind === "global") { + accounts = [...this.launches.keys()]; + } else { + if (scope.provider !== "muse") return []; + const account = accountKey(scope.env); + accounts = this.launches.has(account) ? [account] : []; + } + return accounts.map((account) => ({ key: account, label: "Muse Code", input: { account }, - })), + })); + }, fetch: async (input) => { const { account } = inputSchema.parse(input); const launch = this.launches.get(account); diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 447988e31b..598579d04e 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1,4 +1,6 @@ import { execFile } from "node:child_process"; +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; +import { Usage as MuseUsage } from "../server/usage.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import os from "node:os"; @@ -1415,7 +1417,7 @@ for (const usage of [ test(`usage source presents ${"usage" in usage ? "subscription windows" : "real route absence"} from discovered accounts`, async () => { const h = await harness("catalog-controls", { MUSE_TEST_USAGE: JSON.stringify(usage) }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); const account = inputs[0]!; expect(account).toEqual({ @@ -1566,7 +1568,7 @@ test("usage identity follows the resolved config directory across credential and META_BASE_URL: route!, }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); identities.push(inputs[0]!.key); } @@ -1576,7 +1578,7 @@ test("usage identity follows the resolved config directory across credential and test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => { const { Usage } = await import("../server/usage.js"); - expect(await new Usage().registration().discover()).toEqual([]); + expect(await new Usage().registration().discover({ kind: "global" })).toEqual([]); }); test("Muse window identity follows the reported duration instead of assuming five hours", async () => { @@ -1591,7 +1593,7 @@ test("Muse window identity follows the reported duration instead of assuming fiv }), }); await h.open(); - const [account] = await h.usageSource.discover(); + const [account] = await h.usageSource.discover({ kind: "global" }); expect(await h.usageSource.fetch(account!.input)).toMatchObject({ status: "available", windows: [ @@ -1687,3 +1689,44 @@ async function cleanupStubbornHosts(h: Awaited>) { } } } + +test("Muse reports belong only to the session's account and remain available globally", async () => { + const usage = new MuseUsage(); + const homeEnv = { HOME: "/usage/home" }; + const configEnv = { HOME: "/usage/home", XDG_CONFIG_HOME: "/usage/work" }; + const accountIds = [homeEnv, configEnv].map((env, index) => { + const launch = { command: "muse", args: [], env }; + usage.attach(`session-${index}`, launch, async () => ({ + usage: { + observedAtMs: 1700000000000, + tier: "Pro", + window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 }, + weekly: { usedPercent: 22, resetsAtMs: 1700604800000 }, + }, + })); + return `muse:${usage.remember(launch).account}`; + }); + const sessions = new Map([ + ["claude", { provider: "claude", env: homeEnv, sessionKey: "claude" }], + ["codex", { provider: "codex", env: configEnv, sessionKey: "codex" }], + ["muse-home", { provider: "muse", env: homeEnv, sessionKey: "muse-home" }], + ["muse-work", { provider: "muse", env: configEnv, sessionKey: "muse-work" }], + ["muse-unknown", { provider: "muse", env: { HOME: "/usage/unknown" }, sessionKey: "unknown" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register(usage.registration()); + + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); + expect(await registry.listReports({ agentId: "claude" })).toEqual([]); + expect(await registry.listReports({ agentId: "codex" })).toEqual([]); + for (const [index, agentId] of ["muse-home", "muse-work"].entries()) { + const reports = await registry.listReports({ agentId }); + expect(reports.map((report) => report.id)).toEqual([accountIds[index]]); + expect(reports[0]!.report.status).toBe("available"); + } + expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]); + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); +}); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 760cb1857d..086f910599 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -342,6 +342,7 @@ A source implements two calls: interface UsageAccount { key: string; label?: string; + harness?: string; input: JsonValue; } @@ -375,25 +376,29 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -`discover({ kind: "global" })` queries machine login stores, including expired logins. Session -scope queries only the login stores selected by that harness's resolved launch environment. -Return `[]` when no login exists or the session does not use your source. Never scan default stores -from session discovery or scan agents from global discovery. Discovery is a query, with no agent +Session discovery returns only the login that agent runs on. The agent popover uses only those +inputs; it never tries logins from global discovery or other agents. For any other provider, +return `[]`. Global discovery lists every account, including expired logins. + +Resolve the session account from that session's launch environment. Return `[]` when no login exists. +Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent lifecycle hooks. Closed agents have no session scope until resumed. Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each input against your schema before calling `fetch()`. The same key in any scope is the same report; -agents sharing an account share the fetch cache. Fetches have a 20-second deadline. +fetches share a five-minute cache only when their ordered login inputs match. Each login fetch +has a 20-second deadline. Built-in session routes: -| Source | Session | Login store | -| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | -| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | -| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | -| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | -| Other sources | Any | No session discovery | +| Source | Session | Login store | +| ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | +| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | +| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | +| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | +| Muse | `muse` | That session's own Muse launch/config | +| Other built-in sources | Any | No session discovery | Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions with `OPENAI_BASE_URL` set. @@ -403,9 +408,13 @@ organization whose quota is metered and survives token rotation. Never use a cre email as the key. Use `hashAccountKey()` for sensitive stable identities or a store locator when account metadata is unavailable. Labels can name accounts without becoming their identity. -Return logins in preference order. Several entries with the same key become one card, with their -inputs tried in order until a report is `available`. An unavailable report, error report, or thrown -fetch falls through to the next input. If none succeeds, the card carries the last report. +Return logins in preference order and set `harness` to the owning harness's display name, such as +Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. + +The host-wide Usage screen groups logins with the same key into one account card and tries them +concurrently, preferring the discovery order when selecting a result. If any login returns +`available`, it shows usage with no errors. If all fail, it shows +every login's error on a separate line with its harness label and its own remedy. Discovery failures are logged by the daemon and produce no card. Window names must come from provider data: an explicit duration, a named API field such as @@ -485,7 +494,8 @@ metadata or JWT claims. Pi and OMP logins remain discoverable when expired. OMP `usage.list_reports` discovers accounts when called without IDs. With IDs, it refreshes known accounts without rediscovering identity. If a store switches accounts, the existing card shows the new login's quota until the next discovery. Reports are cached for five minutes; `forceRefresh` -bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Clients gate the feature +bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Failed accounts also carry optional +`loginErrors` entries with `harness` and each login's failed `report`. Clients gate the feature on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports for 0.10 clients and renders problems into its `error` string. From db303be3c2019c6e9a3da50811ccca88ec8beaa3 Mon Sep 17 00:00:00 2001 From: Callum Wong Date: Tue, 6 Oct 2026 05:25:48 +1100 Subject: [PATCH 068/281] fix(nix): ship resolved daemon dependencies and working terminals (#5523) * fix(nix): ship @getpaseo/server's main export in the desktop closure The desktop's runtime-paths.js locates the server package root with require.resolve("@getpaseo/server"), which resolves through the exports map to dist/server/server/exports.js. The desktop never loads that file, so nft doesn't trace it, and the Linux Nix desktop build fails to start the daemon with MODULE_NOT_FOUND. * fix(nix): retain the server resolution anchor for CLI and desktop Run the existing Nix daemon smoke through the built CLI so package-root resolution is exercised before readiness and HTTP checks. Co-authored-by: Callum Wong * fix(nix): ship complete terminal runtimes Resolve runtime dependency edges at their importing modules so package links and exports survive closure tracing regardless of npm hoisting. Use the native loader's selected files and verify terminal commands and hook CLI execution through the built package. Co-authored-by: Callum Wong * test(desktop): make packaged CLI path expectations portable --------- Co-authored-by: Mohamed Boudra --- .github/workflows/nix.yml | 23 ++++- nix/desktop-package.nix | 3 + nix/package.nix | 3 + .../src/integrations/cli-install/path.test.ts | 39 ++++++++- .../src/integrations/cli-install/path.ts | 18 ++++ .../src/integrations/cli-install/paths.ts | 24 ++---- scripts/trace-daemon.mjs | 85 +++++++++++++++---- 7 files changed, 156 insertions(+), 39 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a4a248e1cf..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -47,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -57,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -72,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index d00b382083..dae45db386 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -157,6 +157,9 @@ buildNpmPackage { cp -a "$path" "$out/share/paseo-desktop/$path" done < desktop-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/share/paseo-desktop" + # Keep the same unpackaged monorepo layout expected by main.js. cp package.json $out/share/paseo-desktop/ mkdir -p $out/share/paseo-desktop/packages/app diff --git a/nix/package.nix b/nix/package.nix index 417f3d2ad1..b032d88396 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -120,6 +120,9 @@ buildNpmPackage rec { cp -a "$path" "$out/lib/paseo/$path" done < daemon-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/lib/paseo" + # Root package.json lets node resolve the workspace layout when the # CLI/server bin starts from $out. cp package.json $out/lib/paseo/ diff --git a/packages/desktop/src/integrations/cli-install/path.test.ts b/packages/desktop/src/integrations/cli-install/path.test.ts index 472f066e00..77d340b409 100644 --- a/packages/desktop/src/integrations/cli-install/path.test.ts +++ b/packages/desktop/src/integrations/cli-install/path.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolveCliInstallSourcePath } from "./path"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path"; describe("cli-install-path", () => { it("uses the bundled shim for packaged macOS installs", () => { @@ -56,3 +58,38 @@ describe("cli-install-path", () => { ).toBe("/opt/Paseo/resources/bin/paseo"); }); }); + +describe("CLI executable selection", () => { + const resolveWorkspaceCli = () => + createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo"); + + it("uses the workspace CLI for an unpackaged Electron launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: false, + executablePath: "/nix/store/electron/bin/electron", + resolveWorkspaceCli, + }), + ).toBe(resolveWorkspaceCli()); + }); + + it("uses the application shim for a packaged launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: true, + executablePath: "/opt/Paseo/paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo")); + expect( + resolveCliShimPath({ + platform: "darwin", + isPackaged: true, + executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo")); + }); +}); diff --git a/packages/desktop/src/integrations/cli-install/path.ts b/packages/desktop/src/integrations/cli-install/path.ts index c9114c2301..0cad7fc757 100644 --- a/packages/desktop/src/integrations/cli-install/path.ts +++ b/packages/desktop/src/integrations/cli-install/path.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + export function resolveCliInstallSourcePath(input: { platform: NodeJS.Platform; isPackaged: boolean; @@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: { return input.executablePath; } + +export function resolveCliShimPath(input: { + platform: NodeJS.Platform; + isPackaged: boolean; + executablePath: string; + resolveWorkspaceCli: () => string; +}): string { + if (!input.isPackaged) return input.resolveWorkspaceCli(); + + const filename = input.platform === "win32" ? "paseo.cmd" : "paseo"; + if (input.platform === "darwin") { + const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, ""); + return path.join(bundle, "Contents", "Resources", "bin", filename); + } + return path.join(path.dirname(input.executablePath), "resources", "bin", filename); +} diff --git a/packages/desktop/src/integrations/cli-install/paths.ts b/packages/desktop/src/integrations/cli-install/paths.ts index 312a587892..1f7bf98f48 100644 --- a/packages/desktop/src/integrations/cli-install/paths.ts +++ b/packages/desktop/src/integrations/cli-install/paths.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { createRequire } from "node:module"; +import { resolveCliShimPath } from "./path.js"; import os from "node:os"; import { app } from "electron"; @@ -12,20 +14,10 @@ export function getCliTargetPath(): string { } export function getBundledCliShimPath(): string { - const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo"; - - if (process.platform === "darwin") { - const electronExePath = app.getPath("exe"); - const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); - return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); - } - - if (process.platform === "win32") { - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); - } - - // Linux - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); + return resolveCliShimPath({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: app.getPath("exe"), + resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"), + }); } diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb80266c13..2b6e26c3ea 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -13,7 +13,8 @@ // node_modules populated (the Nix build invokes this post-configHook). import { nodeFileTrace } from "@vercel/nft"; -import { glob } from "node:fs/promises"; +import { glob, readFile, readdir } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -29,6 +30,47 @@ const { sherpaPlatformPackageName } = await import( ); const traceDesktop = process.env.PASEO_TRACE_DESKTOP === "1"; +const terminalModule = "packages/server/dist/server/terminal/terminal.js"; +const sherpaModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.js"; +const sherpaEnvModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +// Resolve-only and computed requires need explicit graph edges, not installed +// paths. Analyze them at their real importer so nft retains the package exports, +// manifests and workspace symlinks used by Node's resolution. +const runtimeDependencies = new Map([ + ["packages/cli/dist/commands/daemon/local-daemon.js", ["@getpaseo/server"]], + [terminalModule, ["@getpaseo/cli/bin/paseo", "node-pty/package.json"]], + [sherpaModule, ["sherpa-onnx-node"]], + [sherpaEnvModule, [`${sherpaPlatformPackageName()}/package.json`]], + ...(traceDesktop + ? [ + ["packages/desktop/dist/daemon/runtime-paths.js", ["@getpaseo/server"]], + ["packages/desktop/dist/integrations/cli-install/paths.js", ["@getpaseo/cli/bin/paseo"]], + ] + : []), +]); + +function requireFrom(importer) { + return createRequire(path.join(REPO_ROOT, importer)); +} + +function resolvedPackageFiles(importer, specifier) { + const manifest = requireFrom(importer).resolve(`${specifier}/package.json`); + return path.join(path.dirname(manifest), "**"); +} + +// Let node-pty select the native build it actually loads. npm hoisting and +// prebuild/build layout are owned by the package, not this trace. Its Darwin +// spawn-helper lives beside the selected addon; retain the files in that dir. +const terminalRequire = requireFrom(terminalModule); +const ptyLoader = terminalRequire.resolve("node-pty/lib/utils"); +const { dir: ptyNativeDir } = terminalRequire(ptyLoader).loadNativeModule("pty"); +const ptyNativeRoot = path.resolve(path.dirname(ptyLoader), ptyNativeDir); +const ptyNativeFiles = (await readdir(ptyNativeRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(ptyNativeRoot, entry.name)); // Daemon entry points. Workers forked into their own Node processes have // independent require trees; nft does not follow fork boundaries, so trace @@ -63,33 +105,40 @@ const additionalInputs = [ "packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs", // Server runtime config files (read by path, not require) "packages/server/.env.example", - // CLI shebang script wrapping dist/index.js - "packages/cli/bin/paseo", - // node-pty's compiled native addon. nft can't trace it because - // node-pty loads it via `require(path.join(__dirname, 'prebuilds//pty.node'))` - // with a runtime-computed platform suffix. Pin to the host platform — - // the Nix derivation builds for one platform at a time and ships only - // its own binaries. - `node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`, - // sherpa-onnx-node dynamically resolves a platform-specific native package. - // Copy the wrapper plus the host platform package explicitly. - "node_modules/sherpa-onnx-node/**", - `node_modules/${sherpaPlatformPackageName()}/**`, + ...ptyNativeFiles, + // Resolve native speech packages from the same modules as the runtime. + resolvedPackageFiles(sherpaModule, "sherpa-onnx-node"), + resolvedPackageFiles(sherpaEnvModule, sherpaPlatformPackageName()), ...(traceDesktop ? [ // The unpackaged Nix launcher resolves these beside desktop/dist. "packages/desktop/package.json", "packages/desktop/assets/**", - // resolveExternalCliEntrypoint() looks up the workspace through this - // link at runtime; nft traces the target files but not the link. - "node_modules/@getpaseo/cli", ] : []), ]; +// Fail at build time if a required runtime edge cannot resolve. +for (const [importer, specifiers] of runtimeDependencies) { + for (const specifier of specifiers) requireFrom(importer).resolve(specifier); +} + // Trace. const { fileList, warnings } = await nodeFileTrace(entries, { base: REPO_ROOT, + async readFile(file) { + let source; + try { + source = await readFile(file); + } catch (error) { + if (error.code === "ENOENT" || error.code === "EISDIR") return null; + throw error; + } + const dependencies = runtimeDependencies.get(path.relative(REPO_ROOT, file)); + if (!dependencies) return source; + // These statements exist only in nft's input, never in the shipped code. + return `${source}\n${dependencies.map((specifier) => `require(${JSON.stringify(specifier)});`).join("\n")}`; + }, // Tolerate the conditional / dynamic patterns we already audited: // sherpa-onnx-${platform}-${arch} package resolution (the host package // is copied explicitly above), and a handful of test-only requires that @@ -129,10 +178,10 @@ const expanded = new Set(fileList); for (const pattern of additionalInputs) { if (pattern.includes("*")) { for await (const file of glob(pattern, { cwd: REPO_ROOT })) { - expanded.add(file); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, file))); } } else { - expanded.add(pattern); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, pattern))); } } From 4ed13fadb63a2d729f1f99eb5512253bcedeb585 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:52:51 +0200 Subject: [PATCH 069/281] fix(app): keep list numbers when a copy crosses a code block nested in an item (#6158) A selection starting after an item's marker demotes that item to a paragraph. When the item holds a code block, Turndown's HTML re-parse splits the paragraph into several siblings, and counting siblings numbered the next item too high ("4. Done." instead of "2. Done."). Each item now carries its number before the re-parse. Closes #6140 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../content.browser.test.ts | 26 +++++++++++++++++++ .../assistant-selection-copy/content.web.ts | 19 +++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 00a64406ea..995927f106 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -320,6 +320,32 @@ describe("assistant selection copy ranges", () => { ).toBe("6. Second bullet text"); }); + it("keeps the next number when a drag crosses a code block nested in an item", () => { + const message = mountFixture(); + const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]'); + list.outerHTML = [ + '
', + '
', + '
1.
', + "
Install it:
", + '
', + '
brew install foo
', + '', + "
", + "
", + '
', + '
2.
', + "
Done.
", + "
", + "
", + ].join(""); + + const content = copyAcross(message, "Install it:", "Done."); + + expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done."); + expect(content?.html).toContain("
2. Done.
"); + }); + it("retains nested markers when a drag includes the complete outer item", () => { const message = mountFixture(); const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]'); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index aa6e6cd91b..ac4643b2ff 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -53,9 +53,7 @@ turndown.addRule("compactListItem", { if (parent?.nodeName !== "OL") { return `${options.bulletListMarker} ${item}\n`; } - const start = Number(parent.getAttribute("start") ?? 1); - const index = Array.from(parent.children).indexOf(node); - return `${start + index}. ${item}\n`; + return `${node.getAttribute("value")}. ${item}\n`; }, }); @@ -536,6 +534,7 @@ function restoreMarkdownElements(container: HTMLElement): void { if (start) { semanticElement.setAttribute("start", start); } + numberOrderedListItems(semanticElement, Number(start ?? 1)); } if (tagName === "pre") { const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE); @@ -568,6 +567,20 @@ function restoreMarkdownElements(container: HTMLElement): void { } } +/** + * Each item carries its number before Turndown re-parses the HTML. Until then every + * child of the list is one item, including a partly selected item demoted to a `p`. + * The parser splits a `p` that holds a code block into several siblings, so counting + * siblings afterwards numbers the next item too high. + */ +function numberOrderedListItems(list: Element, start: number): void { + Array.from(list.children).forEach((child, index) => { + if (child.tagName === "LI") { + child.setAttribute("value", String(start + index)); + } + }); +} + function unwrapIncompleteTables(container: HTMLElement): void { for (const table of container.querySelectorAll("table")) { if (hasUsableTableColumnContract(table)) { From 2f0cb2f54be5742d6fc7e9b85ba39808ac22ad93 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:10:24 +0100 Subject: [PATCH 070/281] Show the Codex speed bolt in yellow while Fast or Ultrafast is selected (#6152) * fix(app): fill the speed icon for accelerated tiers * fix(app): highlight the Codex speed bolt when Fast or Ultrafast is selected Codex speed moved from the Fast toggle to a Speed selector, and the composer only colored toggle features, so the bolt stayed muted for every speed. A select feature is now active when its selected option is not the default, and the Codex speed takes the same yellow highlight as the Fast toggle, on the desktop toolbar and in the compact sheet. Closes #5939 Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com> * test(app): assert the Codex speed bolt is yellow on desktop and compact --------- Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/codex-speed.spec.ts | 17 +++++++++++++ packages/app/src/agent-controls/policy.ts | 1 + .../app/src/composer/agent-controls/index.tsx | 24 ++++++++++++------- .../src/composer/agent-controls/utils.test.ts | 24 +++++++++++++++++++ .../app/src/composer/agent-controls/utils.ts | 13 +++++++++- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/browser/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts index 654f15207d..ca8c3d8991 100644 --- a/packages/app/e2e/browser/codex-speed.spec.ts +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -26,6 +26,10 @@ test.use({ }, }); +// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal. +const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)"; +const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)"; + async function openSpeedSelector(page: Page): Promise { const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); const features = page.getByRole("button", { name: "Open agent features", exact: true }); @@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise { else await expect(sheetTrigger).toContainText(label); } +async function expectSpeedIconColor(page: Page, color: string): Promise { + const trigger = page + .getByRole("button", { name: /^(Speed: .+|Select speed)$/ }) + .filter({ visible: true }); + await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color); +} + async function expectSpeedChoices(page: Page, choices: string[]): Promise { for (const choice of ["Normal", "Fast", "Ultrafast"]) { const option = page.getByText(choice, { exact: true }).last(); @@ -100,15 +111,21 @@ for (const viewport of [ await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); } await openSpeedSelector(page); + await selectSpeed(page, "Fast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) }); + await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); await selectSpeed(page, "Ultrafast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), }); await selectSpeed(page, "Normal"); + await expectSpeedIconColor(page, NORMAL_SPEED_COLOR); if (viewport.name === "desktop") { await switchAgentModel(agent.id, "gpt-6-sol"); await openSpeedSelector(page); diff --git a/packages/app/src/agent-controls/policy.ts b/packages/app/src/agent-controls/policy.ts index 827dfadf57..89a4a86daa 100644 --- a/packages/app/src/agent-controls/policy.ts +++ b/packages/app/src/agent-controls/policy.ts @@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types"; export const PLAN_MODE_FEATURE_ID = "plan_mode"; export const FAST_MODE_FEATURE_ID = "fast_mode"; +export const SPEED_FEATURE_ID = "service_tier"; export function isPlanningAgentMode(mode: Pick): boolean { return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan"); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index d5f09dc77f..72795289cf 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -55,6 +55,7 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getFeatureHighlightColor, + isFeatureActive, getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, @@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { } function getFeatureIconColor( - featureId: string, - enabled: boolean, + feature: AgentFeature, palette: { blue: { 400: string }; green: { 400: string }; @@ -220,11 +220,11 @@ function getFeatureIconColor( }, foregroundMuted: string, ): string { - if (!enabled) { + if (!isFeatureActive(feature)) { return foregroundMuted; } - switch (getFeatureHighlightColor(featureId)) { + switch (getFeatureHighlightColor(feature.id)) { case "blue": return palette.blue[400]; case "green": @@ -1335,8 +1335,7 @@ function DesktopFeatureItem({ { it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); + expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); + + it("treats a select feature as active when a non-default option is selected", () => { + const speed = { + type: "select" as const, + id: "service_tier", + label: "Speed", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }; + expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); + expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); + expect( + isFeatureActive({ + type: "toggle", + id: "fast_mode", + label: "Fast", + value: true, + }), + ).toBe(true); + }); }); describe("normalizeModelId", () => { diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 631b55753e..c0b1da7c47 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,7 +1,11 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy"; +import { + FAST_MODE_FEATURE_ID, + PLAN_MODE_FEATURE_ID, + SPEED_FEATURE_ID, +} from "@/agent-controls/policy"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; @@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick option.id === feature.value); + return selectedOption !== undefined && !selectedOption.isDefault; +} + export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: + case SPEED_FEATURE_ID: return "yellow"; case "auto_accept": return "green"; From f02cc16bb4e0b7267a0c3100c8f5c83236699004 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:06:34 +0200 Subject: [PATCH 071/281] fix(app): close hover cards when the pointer leaves a clicked row (#6167) * fix(app): close hover cards when the pointer leaves a clicked trigger A hover card stayed open while any focus sat inside its trigger or content, so clicking a sidebar workspace row (which focuses the row's button) pinned the card after the pointer moved away, until Escape or a click elsewhere. Only keyboard-visible focus now keeps the card open, which preserves the keyboard entry path added with the shared hover card. * fix(app): keep a hover card open when keyboard focus arrives during its close delay Recheck keyboard focus when the close timer fires, and move the hover card e2e steps into sidebar helpers. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/sidebar-workspace.spec.ts | 46 +++++++++++++++++++ packages/app/e2e/support/helpers/sidebar.ts | 31 +++++++++++++ packages/app/src/components/ui/hover-card.tsx | 11 ++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 700d1e200f..55782a2155 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,6 +6,11 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + expectWorkspaceHoverCardClosed, + expectWorkspaceHoverCardOpen, + expectWorkspaceHoverCardStaysOpen, + focusWorkspaceRowWithKeyboard, + movePointerAwayFromWorkspaceHoverCard, openMobileAgentSidebar, pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; @@ -404,6 +409,47 @@ test.describe("Sidebar workspace list", () => { } }); + test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await row.click(); + await expect(row).toBeFocused(); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + + test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardStaysOpen(page); + await page.keyboard.press("Escape"); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index c2afc34ef8..1909b4f31b 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); } +export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise { + // Two moves, so the pointer always moves even when it is already parked here. + await page.mouse.move(1200, 200, { steps: 5 }); + await page.mouse.move(1200, 400, { steps: 5 }); +} + +export async function focusWorkspaceRowWithKeyboard( + page: Page, + workspaceId: string, +): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await row.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(row).toBeFocused(); +} + +export async function expectWorkspaceHoverCardOpen(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); +} + +export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise { + // Longer than the hover card's close grace period. + await page.waitForTimeout(300); + await expectWorkspaceHoverCardOpen(page); +} + +export async function expectWorkspaceHoverCardClosed(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise { const workspaceKey = `${getServerId()}:${workspaceId}`; await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click(); diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index f6497ce257..e186b39cd0 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -98,14 +98,21 @@ function WebHoverCard({ ); }, []); + // Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves + // behind on the trigger or a button in the card does not outlast the pointer leaving. + const keyboardFocusInside = useCallback(() => { + return focusInside() && document.activeElement?.matches(":focus-visible") === true; + }, [focusInside]); + const scheduleClose = useCallback(() => { - if (focusInside()) return; + if (keyboardFocusInside()) return; if (graceTimerRef.current) return; graceTimerRef.current = setTimeout(() => { graceTimerRef.current = null; + if (keyboardFocusInside()) return; setOpen(false); }, CLOSE_GRACE_MS); - }, [focusInside]); + }, [keyboardFocusInside]); const openNow = useCallback(() => { clearGraceTimer(); From 84054f8d7294285b5a5710b1396f8bdf2c921b1a Mon Sep 17 00:00:00 2001 From: Ruichen Zhou Date: Tue, 6 Oct 2026 06:09:19 +0800 Subject: [PATCH 072/281] fix(desktop): restore titlebar dragging after scrolling chat (#5233) * fix(app): restore desktop screen header dragging * fix(desktop): keep the titlebar draggable after scrolling chat * fix(desktop): preserve global control exclusions without blocking chat headers Keep programmatic focus containers out of the global interactive no-drag rule. Retain semantic controls everywhere, including window-owned sidebar toggles and negative-tabindex buttons. Verify both header rows and independently positioned controls with native input. Co-authored-by: Ruichen Zhou * fix(desktop): isolate chat content from window drag regions Electron includes scrolled interactive rectangles outside their viewport in window hit testing. Reset window regions at the chat viewport while preserving the global control exclusions for chrome and portals. --------- Co-authored-by: Mohamed Boudra --- docs/browser-capture-harness.md | 12 + packages/app/public/index.html | 9 + .../app/src/agent-stream/strategy-web.tsx | 2 +- packages/desktop/capture-harness/main.js | 18 +- .../capture-harness/titlebar-drag-mouse.m | 52 ++++ .../desktop/capture-harness/titlebar-drag.js | 272 ++++++++++++++++++ .../desktop/e2e/workspace-restart.spec.ts | 130 +++++++++ 7 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 packages/desktop/capture-harness/titlebar-drag-mouse.m create mode 100644 packages/desktop/capture-harness/titlebar-drag.js diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 594aacf5f6..f760fafa67 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -43,6 +43,18 @@ Run the shared browser profile fixture with: PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS, test native titlebar dragging after scrolling with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop +``` + +This group needs Xcode Command Line Tools and Accessibility permission for the +terminal running it. It sends real mouse input to its test window and checks +window movement, including button clicks and a portal over the titlebar. Leave +the mouse idle during this group. It reads the app's production CSS and writes +screenshots and window bounds to the output directory. + The browser profile group runs two Electron processes in sequence. It verifies that each renderer-side `did-attach` identity maps to the correct main-process guest, that two live tabs share cookies and local storage through one persistent session, and that the data is diff --git a/packages/app/public/index.html b/packages/app/public/index.html index f7116fc86b..a95e7b8c9e 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -70,6 +70,15 @@ -webkit-app-region: no-drag !important; } + /* Electron does not clip app-region rectangles to scroll viewports. + Content-only viewports emit no window regions, including from their + controls. Visible controls here use normal DOM hit testing. + Use initial: Blink parses the explicit value none as no-drag. */ + [data-window-content], + [data-window-content] * { + -webkit-app-region: initial !important; + } + /* Suppress the browser's default focus outline — it appears on mouse clicks and looks out of place against our themed surfaces. Keep a themed ring for keyboard users via :focus-visible. */ diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 25e7a313d2..d0b7eda0df 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1241,7 +1241,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( -
+
+#import +#import +#import +#import + +static void post(CGEventType type, CGPoint p, useconds_t delay) { + CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft); + if (!event) { + fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n"); + exit(3); + } + CGEventSetFlags(event, (CGEventFlags)0); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + usleep(delay); +} + +int main(int argc, char **argv) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "mouse: not accessibility-trusted\n"); + return 2; + } + if (argc == 6 && strcmp(argv[1], "drag") == 0) { + CGPoint a = {atof(argv[2]), atof(argv[3])}; + CGPoint b = {atof(argv[4]), atof(argv[5])}; + post(kCGEventMouseMoved, a, 150000); + post(kCGEventLeftMouseDown, a, 150000); + for (int i = 1; i <= 20; i++) { + CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20}; + post(kCGEventLeftMouseDragged, step, 25000); + } + post(kCGEventLeftMouseUp, b, 25000); + return 0; + } + if (argc == 4 && strcmp(argv[1], "click") == 0) { + CGPoint p = {atof(argv[2]), atof(argv[3])}; + post(kCGEventMouseMoved, p, 150000); + post(kCGEventLeftMouseDown, p, 150000); + post(kCGEventLeftMouseUp, p, 25000); + return 0; + } + fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]); + return 1; +} diff --git a/packages/desktop/capture-harness/titlebar-drag.js b/packages/desktop/capture-harness/titlebar-drag.js new file mode 100644 index 0000000000..fe4a35d06c --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag.js @@ -0,0 +1,272 @@ +// Native mouse input is required: CDP mouse events do not move Electron windows. +// Load production CSS so restoring the global no-drag rule reproduces the failure. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const { BrowserWindow } = require("electron"); + +const execFileAsync = promisify(execFile); +const PACKAGE_ROOT = path.join(__dirname, "..", ".."); +const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html"); +const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m"); +const BASE = { x: 100, y: 150 }; +const DRAG_DELTA = { x: 80, y: 50 }; +const DRAG_POINTS = [ + { check: "header-overlay-blank", x: 625, y: 18 }, + { check: "tab-row-overlay-blank", x: 625, y: 50 }, + { check: "sidebar-overlay-blank", x: 125, y: 18 }, + { check: "direct-rail-blank", x: 125, y: 50 }, +]; +const CLICK_POINTS = [ + { check: "header-button-click", id: "header-btn", x: 955, y: 18 }, + { check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 }, + { check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 }, + { check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 }, + { check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 }, + { check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 }, + { check: "portal-button-click", id: "portal-btn", x: 490, y: 18 }, + { check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 }, +]; + +/** Static overlays and a sibling window control mirror the app shell ownership. */ +function buildFixtureHtml(appStyles) { + const dragOverlay = + 'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"'; + const button = (id, style) => + ``; + return ` + + + +
+
+
+ +${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")} +
Focusable
+
Button
+
+
+
+ Chat link + ${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")} +
file.ts
+
+
+
+
+ ${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+
+ ${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+`; +} + +function readRealAppStyles() { + const html = fs.readFileSync(INDEX_HTML, "utf8"); + const styles = [...html.matchAll(/]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n"); + if (!styles.trim()) throw new Error(`no ", + ].join("\n"); + + const tokens = highlightCode(code, "Counter.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "let", style: "keyword" }, + { text: "number", style: "type" }, + { text: "template", style: "tag" }, + { text: "v-if", style: "attribute" }, + { text: "count", style: "definition" }, + { text: "1", style: "number" }, + { text: "'a}}b'", style: "string" }, + { text: "inner", style: "definition" }, + { text: "2", style: "number" }, + { text: "badge", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + + it("highlights a Vue SFC that contains an empty interpolation", () => { + const code = [""].join("\n"); + + const tokens = highlightCode(code, "Empty.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "template", style: "tag" }, + { text: "count", style: "variable" }, + ]), + ); + }); + + it("highlights quoted Vue directive values as code", () => { + const code = ""; + + const tokens = highlightCode(code, "Directive.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "count", style: "variable" }, + { text: "0", style: "number" }, + { text: "badge", style: "variable" }, + ]), + ); + expect(tokens).not.toContainEqual({ text: '"count > 0"', style: "string" }); + }); + + it("keeps a Vue interpolation open through nested template strings", () => { + const code = ""; + + const tokens = highlightCode(code, "Nested.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el =
hello
;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 1d0d0ebcdd..b24872eb94 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -25,6 +25,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); expect(isLanguageSupported("Page.astro")).toBe(true); + expect(isLanguageSupported("App.vue")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -67,6 +68,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); expect(extensions).toContain("astro"); + expect(extensions).toContain("vue"); }); }); diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 9ccb3129be..06c3129733 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -18,6 +18,7 @@ import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; import { astroParser } from "./astro/parser.js"; +import { vueParser } from "./vue/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -56,6 +57,8 @@ const languagesByExtension: Record = { svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), // Astro astro: language(astroParser), + // Vue + vue: language(vueParser), // XML xml: language(xmlParser), // Java diff --git a/packages/highlight/src/vue/parser.ts b/packages/highlight/src/vue/parser.ts new file mode 100644 index 0000000000..05010665ca --- /dev/null +++ b/packages/highlight/src/vue/parser.ts @@ -0,0 +1,233 @@ +import { + type Input, + type NestedParse, + type SyntaxNode, + type SyntaxNodeRef, + parseMixed, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; + +interface Range { + from: number; + to: number; +} + +const typescriptParser = jsParser.configure({ dialect: "ts" }); +const expressionParser = jsParser.configure({ top: "SingleExpression" }); + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +// Template strings nested deeper than this are treated as running to the end. +const MAX_TEMPLATE_NESTING = 32; + +function skipQuotedText(text: string, opening: number, nesting = 0): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) { + position = skipTemplateSubstitution(text, position + 2, nesting); + } + } + return text.length - 1; +} + +function skipTemplateSubstitution(text: string, start: number, nesting: number): number { + if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1; + const closing = findClosingBrace(text, start, () => true, nesting + 1); + return closing >= 0 ? closing : text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +// Finds the first `}` outside nested braces, strings, comments, and regexes +// for which `isEnd` holds. +function findClosingBrace( + text: string, + start: number, + isEnd: (position: number) => boolean, + nesting = 0, +): number { + let depth = 0; + for (let position = start; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position, nesting); + } else if (code === 123) { + depth++; + } else if (code === 125 && depth === 0) { + if (isEnd(position)) return position; + } else if (code === 125) { + depth--; + } + } + return -1; +} + +function findInterpolationEnd(text: string, start: number): number { + return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125); +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + if (text.charCodeAt(position + 1) !== 123) continue; + const closing = findInterpolationEnd(text, position + 2); + if (closing < 0) break; + // Lezer rejects empty inner ranges, so `{{}}` gets no nested parse. + if (closing > position + 2) ranges.push({ from: position + 2, to: closing }); + position = closing + 1; + } + return ranges; +} + +function findDirectiveExpression(value: string): Range | null { + const quote = value[0]; + const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote); + const inner = isQuoted ? value.slice(1, -1) : value; + const trimmed = inner.trim(); + if (!trimmed) return null; + const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed); + return { from, to: from + trimmed.length }; +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record { + const attributes: Record = Object.create(null); + const openTag = node.getChild("OpenTag"); + if (!openTag) return attributes; + + for (const attribute of openTag.getChildren("Attribute")) { + const name = attribute.getChild("AttributeName"); + if (!name) continue; + const value = + attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue"); + const key = input.read(name.from, name.to).toLowerCase(); + attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : ""; + } + return attributes; +} + +function isDirectiveAttribute(attribute: string): boolean { + return ( + attribute.startsWith("v-") || + attribute.startsWith(":") || + attribute.startsWith("@") || + attribute.startsWith(".") + ); +} + +function directiveOverlay( + node: SyntaxNodeRef, + parent: SyntaxNode, + input: Input, +): NestedParse | null { + const attributeName = parent.getChild("AttributeName"); + if (!attributeName) return null; + if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) { + return null; + } + + const range = findDirectiveExpression(input.read(node.from, node.to)); + if (!range) return null; + return { + parser: expressionParser, + overlay: [{ from: node.from + range.from, to: node.from + range.to }], + }; +} + +function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from, + to: node.from + to, + })); + return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null; +} + +function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (parent?.name === "Attribute") return directiveOverlay(node, parent, input); + if (node.name !== "Text") return null; + return textOverlay(node, input); +} + +function scriptLanguage(attributes: Record): NestedParse | null { + if (attributes.src) return null; + + const language = (attributes.lang || attributes.type || "").toLowerCase(); + if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) }; + if (language.includes("typescript") || language === "ts") return { parser: typescriptParser }; + if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) }; + return { parser: jsParser }; +} + +function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (!parent) return null; + return scriptLanguage(getOpenTagAttributes(parent, input)); +} + +function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + const name = node.name; + if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") { + return templateOverlay(node, input); + } + if (name === "StyleText") return { parser: cssParser }; + if (name === "ScriptText") return scriptOverlay(node, input); + return null; +} + +export const vueParser = htmlParser.configure({ + wrap: parseMixed(nestedLanguage), +}); From 2974d7c960167541d18c10939a0db5726d2a7896 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:59:59 +0200 Subject: [PATCH 085/281] fix(desktop): keep browser_type text out of the focused Paseo composer (#6207) browser_type committed its text with CDP Input.insertText on the tab's debugger. Chromium commits that text into the focused element of the whole Paseo window, so when the user was typing in an agent composer and the browser tab did not hold focus, the agent's text landed in the composer instead of the page. Insert the text with the tab's own webContents.insertText, which commits into the tab's focused element regardless of window focus. Fixes #6182 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/desktop/capture-harness/main.js | 3 +- packages/desktop/e2e/browser-tabs.e2e.mjs | 72 ++++++++++++++++++- .../features/browser-automation/ipc.test.ts | 2 + .../src/features/browser-automation/ipc.ts | 2 + .../browser-automation/service.test.ts | 29 ++++++-- .../features/browser-automation/service.ts | 6 +- .../browser-automation/trusted-input.ts | 7 -- 7 files changed, 102 insertions(+), 19 deletions(-) diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index f657bfb2b6..5501ff8ba4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1574,9 +1574,8 @@ async function automationDrag(guest, sourceRef, targetRef) { } async function automationType(guest, refEntry, text) { - const send = await attachAutomationDebugger(guest); await automationClick(guest, refEntry); - await send("Input.insertText", { text }); + await guest.insertText(text); } function sendContainedEnter(guest) { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 6647af7b31..b96129e317 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -272,7 +272,7 @@ async function waitForGuestActiveElement(client, browserId, elementId) { return false; } -async function createCallerAgent(daemonPort) { +async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), ); @@ -282,7 +282,7 @@ async function createCallerAgent(daemonPort) { name: "create_agent", args: { relationship: { kind: "detached" }, - workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + workspace: { kind: "existing", workspaceId }, title: "Browser desktop browser E2E caller", provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, @@ -297,7 +297,7 @@ async function createCallerAgent(daemonPort) { ); assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); assert( - result.workspaceId === workspaceIds[0], + result.workspaceId === workspaceId, `MCP caller attached to unexpected workspace ${result.workspaceId}`, ); return result.agentId; @@ -562,12 +562,66 @@ async function verifyHiddenBrowserScreenshots({ await expectAnimation(); } +async function verifyBackgroundTypingStaysInGuest({ page, client, serverId, browserId, agent }) { + await page + .getByTestId(`workspace-deck-entry-${serverId}:${agent.workspaceId}`) + .getByTestId(`workspace-tab-agent_${agent.agentId}`) + .click({ timeout: timeoutMs }); + const composer = page + .getByRole("textbox", { name: "Message agent..." }) + .filter({ visible: true }) + .first(); + const draft = "draft the user is typing"; + await composer.click(); + await composer.pressSequentially(draft); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not keep focus while the user typed", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/textbox "Typing target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the typing target: ${snapshot.snapshot}`); + await callBrowserTool(client, "browser_type", { browserId, ref, text: "agent text" }); + await callBrowserTool(client, "browser_keypress", { browserId, ref, key: "!" }); + + // The user keeps typing in the composer while the agent types into the tab's focused field. + await composer.click(); + await composer.press("End"); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not take focus back from the browser tab", + ); + await callBrowserTool(client, "browser_type", { browserId, text: " more" }); + await callBrowserTool(client, "browser_keypress", { browserId, key: "?" }); + + const guestValue = JSON.parse( + ( + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.getElementById('typing-target').value", + }) + ).resultJson, + ); + const composerValue = await composer.inputValue(); + assert( + composerValue === draft, + `browser_type and browser_keypress in a background tab changed the focused composer: ${JSON.stringify(composerValue)}`, + ); + assert( + guestValue === "agent text! more?", + `browser_type and browser_keypress did not reach the browser tab: ${JSON.stringify(guestValue)}`, + ); + await composer.fill(""); +} + async function runRegression({ page, client, serverId, targetUrl, callerAgentId, + typingAgent, artifactDir, inspectorPort, }) { @@ -857,6 +911,13 @@ async function runRegression({ text: "Clicked", timeoutMs: 5_000, }); + await verifyBackgroundTypingStaysInGuest({ + page, + client, + serverId, + browserId, + agent: typingAgent, + }); await originalWorkspaceRow.click(); await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); @@ -1157,6 +1218,10 @@ async function main() { await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); + const typingAgent = { + workspaceId: workspaceIds.at(-1), + agentId: await createCallerAgent(daemonPort, workspaceIds.at(-1)), + }; const transport = new StreamableHTTPClientTransport( new URL( `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, @@ -1170,6 +1235,7 @@ async function main() { targetUrl: target.url, inspectorPort, callerAgentId, + typingAgent, artifactDir, }); const pluginLinks = await checkPluginLinks(); diff --git a/packages/desktop/src/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index ee7143adc6..427e86e1b1 100644 --- a/packages/desktop/src/features/browser-automation/ipc.test.ts +++ b/packages/desktop/src/features/browser-automation/ipc.test.ts @@ -190,6 +190,8 @@ class FakeWebContents { this.inputEvents.push(event); } + public async insertText(_text: string): Promise {} + public on(event: "console-message", listener: ConsoleMessageListener): void { expect(event).toBe("console-message"); this.consoleMessageListener = listener; diff --git a/packages/desktop/src/features/browser-automation/ipc.ts b/packages/desktop/src/features/browser-automation/ipc.ts index 42c38d2e2c..18e5b84544 100644 --- a/packages/desktop/src/features/browser-automation/ipc.ts +++ b/packages/desktop/src/features/browser-automation/ipc.ts @@ -111,6 +111,7 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter { getBackgroundThrottling(): boolean; setBackgroundThrottling(allowed: boolean): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + insertText(text: string): Promise; } export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents { @@ -143,6 +144,7 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon } }, sendInputEvent: (event) => contents.sendInputEvent(event), + insertText: (text) => contents.insertText(text), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), sendDebugCommand: (command: string, params?: Record) => diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index dee624a3f7..6bc054c6a5 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -41,6 +41,7 @@ class FakeTab implements TabContents { public readonly actions: string[] = []; public readonly capturedViewports: AbortSignal[] = []; public readonly debugCommands: Array<{ command: string; params?: Record }> = []; + public readonly insertedText: string[] = []; public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; private readonly captureStartWaiters: Array<() => void> = []; private readonly deferredCaptures: Array<(image: TabImage) => void> = []; @@ -231,6 +232,10 @@ class FakeTab implements TabContents { this.inputEvents.push(event); } + public async insertText(text: string): Promise { + this.insertedText.push(text); + } + public waitForCaptureStart(count: number): Promise { if (this.capturedViewports.length >= count) { return Promise.resolve(); @@ -1104,17 +1109,31 @@ describe("executeAutomationCommand", () => { ok: true, result: { command: "type", browserId: BROWSER_A, ref: "@e1", x: 40, y: 30 }, }); - expect(browser.tab.debugCommands.at(-1)).toEqual({ - command: "Input.insertText", - params: { text: "Ada" }, - }); - expect(browser.tab.debugCommands.slice(0, 3).map((entry) => entry.command)).toEqual([ + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands.map((entry) => entry.command)).toEqual([ "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", ]); }); + test("type inserts text through the tab, not the window-wide debugger input", async () => { + const browser = new BrowserAutomationHarness(); + + const action = await browser.execute({ + command: "type", + args: { browserId: BROWSER_A, text: "Ada" }, + }); + + expect(action).toEqual({ + requestId: "req-type", + ok: true, + result: { command: "type", browserId: BROWSER_A }, + }); + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands).toEqual([]); + }); + test("keypress dispatches a trusted key to the focused element when ref is omitted", async () => { const browser = new BrowserAutomationHarness(); diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index ca6247e543..fe07adecd3 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -17,7 +17,6 @@ import { dispatchTrustedHover, dispatchTrustedKey, dispatchTrustedScroll, - dispatchTrustedText, type ClickInputOptions, type IsolatedKeyboardInputEvent, } from "./trusted-input.js"; @@ -39,6 +38,9 @@ export interface TabContents { invalidate(): void; withFrameProduction(capture: () => Promise): Promise; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + // Commits text into this tab's focused element. CDP Input.insertText would + // commit into whichever element has focus in the Paseo window instead. + insertText(text: string): Promise; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?( task: () => Promise, @@ -1076,7 +1078,7 @@ async function executeType( } await dispatchTrustedClick(cdpSender(target.contents), actionable.target.point); } - await dispatchTrustedText(cdpSender(target.contents), text); + await target.contents.insertText(text); return { requestId, ok: true, diff --git a/packages/desktop/src/features/browser-automation/trusted-input.ts b/packages/desktop/src/features/browser-automation/trusted-input.ts index 671c5dbda1..8473263a1c 100644 --- a/packages/desktop/src/features/browser-automation/trusted-input.ts +++ b/packages/desktop/src/features/browser-automation/trusted-input.ts @@ -154,13 +154,6 @@ export async function dispatchTrustedScroll( }); } -export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise { - if (text.length === 0) { - return; - } - await send("Input.insertText", { text }); -} - export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void { const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key; let character: string | null = null; From b7c8be408143e00f88f5756e874729232d28c68f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:55:44 +0200 Subject: [PATCH 086/281] Show the PR panel's Activity and Add to chat labels in the app language (#6208) * fix(app): show PR panel activity and add-to-chat labels in the app language The PR panel's Activity header, its empty state, every "Add to chat" button, "Add all to chat", "Adding...", and the comment and thread menu labels were written in the components, so they stayed English in every other language. The sidebar status group rows also appended an English " group" to their accessibility label. They now come from the translation files, with copy for all nine supported languages. Closes #6203 * test(app): check the new PR panel labels are translated in every language --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../sidebar/sidebar-status-list.tsx | 3 +- .../git/pull-request-panel/checks-section.tsx | 5 +- .../app/src/git/pull-request-panel/pane.tsx | 22 +++++---- packages/app/src/i18n/resources.test.ts | 46 +++++++++++++++++++ packages/app/src/i18n/resources/ar.ts | 8 ++++ packages/app/src/i18n/resources/en.ts | 8 ++++ packages/app/src/i18n/resources/es.ts | 8 ++++ packages/app/src/i18n/resources/fr.ts | 8 ++++ packages/app/src/i18n/resources/ja.ts | 8 ++++ packages/app/src/i18n/resources/ko.ts | 8 ++++ packages/app/src/i18n/resources/pt-BR.ts | 8 ++++ packages/app/src/i18n/resources/ru.ts | 8 ++++ packages/app/src/i18n/resources/zh-CN.ts | 8 ++++ 13 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 051121fd3f..c299d07987 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -408,6 +408,7 @@ function StatusGroupHeader({ group: SidebarWorkspaceGroup; collapsed: boolean; }) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleWorkspaceGroupCollapsed, @@ -431,7 +432,7 @@ function StatusGroupHeader({ void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -220,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..4b468e14e2 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -815,6 +815,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +885,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +936,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +963,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +997,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1080,7 +1082,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1126,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 2b83055774..039242710e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -74,6 +74,34 @@ const untranslatedLocalFallbacks = [ "Unable to save desktop settings.", ] as const; +const pullRequestPanelSources = [ + "git/pull-request-panel/pane.tsx", + "git/pull-request-panel/checks-section.tsx", + "components/sidebar/sidebar-status-list.tsx", +] as const; +const untranslatedPullRequestPanelLabels = [ + "Activity", + "No activity yet", + "Add all to chat", + "Add to chat", + "Adding...", + "Comment actions", + "Thread actions", +] as const; + +function findUntranslatedPullRequestPanelLabels(): string[] { + return pullRequestPanelSources.flatMap((source) => { + const contents = readFileSync(join(appSourceRoot, source), "utf8"); + const matches: string[] = untranslatedPullRequestPanelLabels.filter( + (text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents), + ); + if (contents.includes("} group`")) { + matches.push("group"); + } + return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`]; + }); +} + function collectSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -204,6 +232,24 @@ describe("translation resources", () => { expect(en.workspace.fileActions.addToChat).toBe("Add to chat"); }); + it("keeps pull request panel and sidebar status group labels translated", () => { + expect(findUntranslatedPullRequestPanelLabels()).toEqual([]); + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + const pr = resource.workspace.git.pr; + const englishPr = en.workspace.git.pr; + expect(pr.sections.activity).not.toBe(englishPr.sections.activity); + expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity); + expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat); + expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat); + expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat); + expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions); + expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions); + expect(resource.sidebar.statusGroupAccessibility).not.toBe( + en.sidebar.statusGroupAccessibility, + ); + } + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8a6338fe1e..5b684c4545 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -973,6 +973,9 @@ export const ar: TranslationResources = { actions: { viewPullRequest: "عرض", openOn: "فتح على {{brand}}", + addToChat: "إضافة إلى الدردشة", + addAllToChat: "إضافة الكل إلى الدردشة", + addingToChat: "جارٍ الإضافة...", }, checksOverview: { headline: { @@ -1024,17 +1027,21 @@ export const ar: TranslationResources = { checks: "الشيكات", pipeline: "خط المعالجة", reviews: "التعليقات", + activity: "النشاط", }, empty: { noJobs: "لا توجد مهام", loadingPipeline: "جارٍ تحميل خط المعالجة...", pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة", allowedToFail: "مسموح بالفشل", + noActivity: "لا يوجد نشاط بعد", }, approvals: "{{given}} من {{required}} موافقات", accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + commentActions: "إجراءات التعليق", + threadActions: "إجراءات سلسلة النقاش", checkStatus: { passed: "ناجح", failed: "فاشل", @@ -1129,6 +1136,7 @@ export const ar: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "مجموعة {{label}}", statusBucket: { needsInput: "تحتاج إدخالاً", failed: "فشل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5ae4bc29cd..35230d4aaa 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -981,6 +981,9 @@ export const en = { actions: { viewPullRequest: "View", openOn: "Open on {{brand}}", + addToChat: "Add to chat", + addAllToChat: "Add all to chat", + addingToChat: "Adding...", }, checksOverview: { headline: { @@ -1032,17 +1035,21 @@ export const en = { checks: "Checks", pipeline: "Pipeline", reviews: "Reviews", + activity: "Activity", }, empty: { noJobs: "No jobs", loadingPipeline: "Loading pipeline…", pipelineJobsLoadFailed: "Could not load pipeline jobs", allowedToFail: "allowed to fail", + noActivity: "No activity yet", }, approvals: "{{given}} of {{required}} approvals", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Comment actions", + threadActions: "Thread actions", checkStatus: { passed: "Passed", failed: "Failed", @@ -1137,6 +1144,7 @@ export const en = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} group", statusBucket: { needsInput: "Needs input", failed: "Failed", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1c34d7f5b6..18346ad126 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1006,6 +1006,9 @@ export const es: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir en {{brand}}", + addToChat: "Añadir al chat", + addAllToChat: "Añadir todo al chat", + addingToChat: "Añadiendo...", }, checksOverview: { headline: { @@ -1057,17 +1060,21 @@ export const es: TranslationResources = { checks: "cheques", pipeline: "Pipeline", reviews: "Reseñas", + activity: "Actividad", }, empty: { noJobs: "Sin trabajos", loadingPipeline: "Cargando pipeline...", pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline", allowedToFail: "permitido fallar", + noActivity: "Aún no hay actividad", }, approvals: "{{given}} de {{required}} aprobaciones", accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + commentActions: "Acciones del comentario", + threadActions: "Acciones del hilo", checkStatus: { passed: "Superado", failed: "Fallido", @@ -1165,6 +1172,7 @@ export const es: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Necesita datos", failed: "Con error", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c682d05baf..6f21c9ad90 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -997,6 +997,9 @@ export const fr: TranslationResources = { actions: { viewPullRequest: "Voir", openOn: "Ouvrir sur {{brand}}", + addToChat: "Ajouter à la conversation", + addAllToChat: "Tout ajouter à la conversation", + addingToChat: "Ajout…", }, checksOverview: { headline: { @@ -1048,17 +1051,21 @@ export const fr: TranslationResources = { checks: "Vérifications", pipeline: "Pipeline", reviews: "Revues", + activity: "Activité", }, empty: { noJobs: "Aucun job", loadingPipeline: "Chargement du pipeline…", pipelineJobsLoadFailed: "Impossible de charger les jobs du pipeline", allowedToFail: "échec autorisé", + noActivity: "Aucune activité pour le moment", }, approvals: "{{given}} sur {{required}} approbations", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Actions du commentaire", + threadActions: "Actions du fil", checkStatus: { passed: "Réussie", failed: "Échec", @@ -1156,6 +1163,7 @@ export const fr: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Groupe {{label}}", statusBucket: { needsInput: "Attend une réponse", failed: "Échec", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 7d562247b2..14fc6f2f69 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -986,6 +986,9 @@ export const ja: TranslationResources = { actions: { viewPullRequest: "表示", openOn: "{{brand}}で開く", + addToChat: "チャットに追加", + addAllToChat: "すべてチャットに追加", + addingToChat: "追加中...", }, checksOverview: { headline: { @@ -1037,17 +1040,21 @@ export const ja: TranslationResources = { checks: "チェック", pipeline: "パイプライン", reviews: "レビュー", + activity: "アクティビティ", }, empty: { noJobs: "ジョブなし", loadingPipeline: "パイプラインを読み込み中...", pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした", allowedToFail: "失敗を許可", + noActivity: "まだアクティビティはありません", }, approvals: "{{given}} / {{required}} 承認", accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + commentActions: "コメントの操作", + threadActions: "スレッドの操作", checkStatus: { passed: "成功", failed: "失敗", @@ -1142,6 +1149,7 @@ export const ja: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} グループ", statusBucket: { needsInput: "入力待ち", failed: "失敗", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 5b325a5be6..95ac1e2bbd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -980,6 +980,9 @@ export const ko: TranslationResources = { actions: { viewPullRequest: "보기", openOn: "{{brand}}에서 열기", + addToChat: "채팅에 추가", + addAllToChat: "모두 채팅에 추가", + addingToChat: "추가 중...", }, checksOverview: { headline: { @@ -1031,17 +1034,21 @@ export const ko: TranslationResources = { checks: "검사", pipeline: "파이프라인", reviews: "리뷰", + activity: "활동", }, empty: { noJobs: "작업 없음", loadingPipeline: "파이프라인 로드 중…", pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.", allowedToFail: "실패가 허용됨", + noActivity: "아직 활동이 없습니다", }, approvals: "{{required}} 중 {{given}} 승인", accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + commentActions: "댓글 작업", + threadActions: "스레드 작업", checkStatus: { passed: "통과", failed: "실패", @@ -1136,6 +1143,7 @@ export const ko: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 그룹", statusBucket: { needsInput: "입력 필요", failed: "실패", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d86b0f58d3..626dec1e3c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -996,6 +996,9 @@ export const ptBR: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir no {{brand}}", + addToChat: "Adicionar ao chat", + addAllToChat: "Adicionar tudo ao chat", + addingToChat: "Adicionando...", }, checksOverview: { headline: { @@ -1047,17 +1050,21 @@ export const ptBR: TranslationResources = { checks: "Verificações", pipeline: "Pipeline", reviews: "Revisões", + activity: "Atividade", }, empty: { noJobs: "Sem jobs", loadingPipeline: "Carregando pipeline...", pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline", allowedToFail: "permitido falhar", + noActivity: "Nenhuma atividade ainda", }, approvals: "{{given}} de {{required}} aprovações", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Ações do comentário", + threadActions: "Ações da conversa", checkStatus: { passed: "Aprovado", failed: "Falhou", @@ -1155,6 +1162,7 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Precisa de resposta", failed: "Com falha", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ac5ab8d2e0..59562e626b 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -990,6 +990,9 @@ export const ru: TranslationResources = { actions: { viewPullRequest: "Просмотреть", openOn: "Открыть на {{brand}}", + addToChat: "Добавить в чат", + addAllToChat: "Добавить всё в чат", + addingToChat: "Добавление...", }, checksOverview: { headline: { @@ -1041,17 +1044,21 @@ export const ru: TranslationResources = { checks: "Проверки", pipeline: "Пайплайн", reviews: "Ревью", + activity: "Активность", }, empty: { noJobs: "Нет заданий", loadingPipeline: "Загрузка пайплайна...", pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна", allowedToFail: "допускается сбой", + noActivity: "Активности пока нет", }, approvals: "Одобрено: {{given}} из {{required}}", accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + commentActions: "Действия с комментарием", + threadActions: "Действия с обсуждением", checkStatus: { passed: "Успешно", failed: "Ошибка", @@ -1146,6 +1153,7 @@ export const ru: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Группа «{{label}}»", statusBucket: { needsInput: "Ожидает ввода", failed: "Ошибка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7120b7d80e..deb75a2a92 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -965,6 +965,9 @@ export const zhCN: TranslationResources = { actions: { viewPullRequest: "查看", openOn: "在 {{brand}} 上打开", + addToChat: "添加到聊天", + addAllToChat: "全部添加到聊天", + addingToChat: "正在添加...", }, checksOverview: { headline: { @@ -1016,17 +1019,21 @@ export const zhCN: TranslationResources = { checks: "Checks", pipeline: "流水线", reviews: "Reviews", + activity: "动态", }, empty: { noJobs: "无作业", loadingPipeline: "正在加载流水线...", pipelineJobsLoadFailed: "无法加载流水线作业", allowedToFail: "允许失败", + noActivity: "暂无动态", }, approvals: "{{given}} / {{required}} 批准", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + commentActions: "评论操作", + threadActions: "讨论串操作", checkStatus: { passed: "成功", failed: "失败", @@ -1121,6 +1128,7 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 分组", statusBucket: { needsInput: "需要输入", failed: "失败", From 543e3712e186cb0c884182480369131b039e50ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 12:31:49 +0100 Subject: [PATCH 087/281] fix(app): preserve chat rows across virtual layout changes (#6172) * fix(app): preserve timeline rows across virtual layout changes Keep recent and virtual history rows under one keyed host so entering virtual history preserves their DOM and loaded image state. Initialize the virtual range from the mounted viewport to avoid an empty handoff commit. Measure committed row dimensions before accepting reading geometry, including child layout changes flushed during resize observation. Separate row content from position updates while retaining live revision signals. Add a regression covering row identity through virtualization and the position of content following a growing row. Keep the existing browser assertions unchanged. * test(app): verify child state survives timeline virtualization --- .../src/agent-stream/strategy-web.test.tsx | 95 ++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 130 +++++++++++++----- 2 files changed, 186 insertions(+), 39 deletions(-) diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 9078b0f1c3..1f61d0a14c 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({ withUnistyles: (Component: React.ComponentType) => Component, })); -function userMessage(index: number): StreamItem { +function userMessage(index: number): Extract { return { kind: "user_message", id: `message-${index}`, @@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers { }; } +function MeasuredTestRow({ item }: { item: StreamItem }) { + const [loaded, setLoaded] = React.useState(false); + const load = React.useCallback(() => setLoaded(true), []); + const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24; + const measure = React.useCallback( + (node: HTMLDivElement | null) => { + if (!node?.parentElement) return; + Object.defineProperty(node.parentElement, "offsetHeight", { + configurable: true, + value: height, + }); + }, + [height], + ); + return ( +
+ + {item.id} +
+ ); +} + describe("createWebStreamStrategy", () => { let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("retains a row's DOM and local state when recent history becomes virtualized", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const item = userMessage(0); + const renderRow = (rowItem: StreamItem) => ; + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] }, + boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false }, + renderers: { + renderHistoryVirtualizedRow: renderRow, + renderHistoryMountedRow: renderRow, + renderLiveHeadRow: renderRow, + renderLiveAuxiliary: () => null, + }, + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + act(() => root?.render(strategy.render(input))); + const row = container.querySelector('[data-history-row-id="message-0"]')!; + act(() => row.querySelector("button")!.click()); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 }); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row); + expect(row.isConnected).toBe(true); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { + historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)], + historyMounted: [userMessage(2)], + liveHead: [], + }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect( + (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform, + ).toBe("translateY(120px)"); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + }); + it("keeps the timeline width stable with an overlay scrollbar", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); container = document.createElement("div"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index d0b7eda0df..226b7c1e2a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = { flexShrink: 0, }; +const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; + const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom( return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } +// Position updates do not change a row's content. Keep renderer work separate +// from virtual measurements while retaining the same component across lanes. +const StreamRowContent = React.memo(function StreamRowContent({ + item, + index, + items, + render, +}: { + revision?: StreamRenderInput["liveHeadRowRevision"]; + item: StreamItem; + index: number; + items: StreamItem[]; + render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"]; +}) { + return render(item, index, items); +}); + function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments: inputSegments, @@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, + initialRect: { + width: scrollContainerRef.current?.clientWidth ?? 0, + height: scrollContainerRef.current?.clientHeight ?? 0, + }, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { @@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - // ResizeObserver runs before paint. Batch measurements and commit React's new - // positions here so a resized image cannot overlap rows for an extra frame. - // Use resizeItem exclusively; measureElement would install a second observer. + const measureVirtualizedRows = useStableEvent(() => { + let resized = false; + for (const element of contentRef.current?.querySelectorAll( + "[data-history-row-id][data-index]", + ) ?? []) { + const index = Number(element.dataset.index); + const height = measureVirtualElement(element, undefined, rowVirtualizer); + if (rowVirtualizer.measurementsCache[index]?.size === height) continue; + rowVirtualizer.resizeItem(index, height); + resized = true; + } + return resized; + }); + + // Measure the committed DOM rather than queued observer entries: flushSync can + // also commit an image's pending intrinsic dimensions, invalidating those entries. const virtualRowObserver = useMemo( () => - new ResizeObserver((entries) => { + new ResizeObserver(() => { if (!isActiveRef.current) return; flushSync(() => { - for (const entry of entries) { - const element = entry.target as HTMLElement; - if (!element.isConnected) continue; - rowVirtualizer.resizeItem( - Number(element.dataset.index), - measureVirtualElement(element, entry, rowVirtualizer), - ); - } + measureVirtualizedRows(); }); }), - [rowVirtualizer], + [measureVirtualizedRows], ); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Apply row positions and their scroll correction in the same paint, including // the ordinary-to-virtualized handoff and measurements of newly mounted rows. useLayoutEffect(() => { + // Commit positions for every resized row before accepting reading geometry. + // Child layout can change during this commit, ahead of ResizeObserver delivery. + if (!isActive || measureVirtualizedRows()) return; reconcileReadingPosition(); - }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); + }, [ + contentWidth, + isActive, + segments, + virtualRows, + measureVirtualizedRows, + reconcileReadingPosition, + ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); @@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool evaluateHistoryStart(); } const observer = new ResizeObserver(() => { + flushSync(() => { + measureVirtualizedRows(); + }); const nextGeometry = getObservedViewportGeometry(scrollContainer); if (pendingResumeGeometryCheckRef.current) { pendingResumeGeometryCheckRef.current = false; @@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleHistoryStartPrependSettle, scheduleStickToBottom, updateScrollMetrics, + measureVirtualizedRows, ]); useLayoutEffect(() => { @@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [], ); - const virtualRowsContainerStyle = useMemo((): CSSProperties => { - return { - position: "relative", - width: "100%", + const virtualRowsContainerStyle = useMemo( + (): CSSProperties => ({ height: virtualTotalSize, - }; - }, [virtualTotalSize]); + flexShrink: 0, + }), + [virtualTotalSize], + ); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", @@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool data-message-id={getStreamItemMessageId(item)} style={streamRowStyle} > - {renderHistoryMountedRow(item, index, segments.historyMounted)} +
)); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { - void liveHeadRowRevision; return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); @@ -1251,13 +1305,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool >
{historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { +
+
+ {[ + ...virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } + if (!item) return null; return (
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} +
); - })} -
- ) : null} - {mountedRows} + }), + ...mountedRows, + ]} +
{liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}
From 4ede81e84138e5c176de2b80fba38e97005b469e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 12:54:09 +0100 Subject: [PATCH 088/281] Open Usage as a modal over the current screen (#6216) * Open Usage as a modal over the current screen The sidebar Usage icon and summary now open Usage in the adaptive modal (a dialog on wide layouts, a bottom sheet on compact ones) instead of navigating to the /usage screen, so closing it leaves you where you were. The /usage route and UsageScreen are removed. * Reset Usage modal cards when the host changes; keep scroll test body linear --- docs/glossary.md | 4 +- docs/plugins.md | 2 +- ...age-screen.spec.ts => usage-modal.spec.ts} | 43 +++-- .../app/e2e/browser/usage-settings.spec.ts | 9 +- .../e2e/browser/usage-sidebar-item.spec.ts | 173 +++++++++++------- .../app/e2e/support/helpers/usage-login.ts | 4 - .../e2e/support/helpers/usage-sidebar-item.ts | 64 ++++--- packages/app/src/app/_layout.tsx | 2 - packages/app/src/app/usage.tsx | 10 - .../components/sidebar/sidebar-popover.tsx | 2 +- packages/app/src/usage/controls.tsx | 3 +- packages/app/src/usage/hosts.ts | 6 +- packages/app/src/usage/index.ts | 1 - packages/app/src/usage/model.test.ts | 20 +- packages/app/src/usage/model.ts | 6 +- packages/app/src/usage/sidebar-item.tsx | 102 ++--------- packages/app/src/usage/usage-modal.tsx | 93 ++++++++++ packages/app/src/usage/usage-screen.tsx | 72 -------- packages/app/src/utils/host-routes.ts | 4 - public-docs/plugins/reference.md | 2 +- 20 files changed, 308 insertions(+), 314 deletions(-) rename packages/app/e2e/browser/{usage-screen.spec.ts => usage-modal.spec.ts} (94%) delete mode 100644 packages/app/src/app/usage.tsx create mode 100644 packages/app/src/usage/usage-modal.tsx delete mode 100644 packages/app/src/usage/usage-screen.tsx diff --git a/docs/glossary.md b/docs/glossary.md index f9eb458173..3ed97d1fc3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). -- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index 387f24f46b..7cabeeda84 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -488,7 +488,7 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. 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. diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts similarity index 94% rename from packages/app/e2e/browser/usage-screen.spec.ts rename to packages/app/e2e/browser/usage-modal.spec.ts index 1dfe89ae19..6f546078bf 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -18,13 +18,15 @@ import { installLoginUsage, installCodexWindowUsage, expectCodexReportedWindows, - openUsage, refreshLoginUsage, hoverUsageWindow, } from "../support/helpers/usage-login"; import { claudeAndCodexReports, expectPinnedUsage, + openCompactSidebar, + openUsageFromIcon, + openUsageFromItem, pinRow, openUsageOptions, closeUsageOptions, @@ -98,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } -test.describe("usage screen", () => { +test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, }) => { @@ -134,14 +136,13 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); - await expect(page).toHaveURL(/\/usage$/); + await openUsageFromItem(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); - await qaScreenshot(page, "phase7-usage-screen-one-host"); + await qaScreenshot(page, "usage-modal-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -176,7 +177,7 @@ test.describe("usage screen", () => { windows: [{ id: "weekly", label: "Weekly", usedPct }], }, }); - // The sidebar summary and the screen each load reports; only a card's Refresh forces one. + // The sidebar summary and the modal each load reports; only a card's Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ (request) => @@ -187,7 +188,7 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); @@ -224,7 +225,9 @@ test.describe("usage screen", () => { }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/usage"); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); @@ -238,8 +241,8 @@ test.describe("usage screen", () => { const usage = await installUsageReportsFixture(page, { usageSupported: false }); await gotoAppShell(page); - // Without reports the footer has no Usage item; its Usage icon opens the screen. - await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click(); + // Without reports the footer has no Usage item; its Usage icon opens Usage. + await openUsageFromIcon(page); await expect( page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), @@ -250,7 +253,7 @@ test.describe("usage screen", () => { expect(usage.listRequests()).toHaveLength(0); }); - test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({ + test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({ page, }) => { test.setTimeout(420_000); @@ -273,7 +276,7 @@ test.describe("usage screen", () => { // Nothing picked and no workspace open: the first host. await expectPinnedUsage(page, ["31% Weekly"]); - await usageItem(page).click(); + await openUsageFromItem(page); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); await hostFilter(page).click(); @@ -282,7 +285,7 @@ test.describe("usage screen", () => { page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); - await qaScreenshot(page, "usage-screen-picked-host"); + await qaScreenshot(page, "usage-modal-picked-host"); await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. @@ -292,6 +295,7 @@ test.describe("usage screen", () => { port: secondary.port, }); await expectPinnedUsage(page, ["12% Weekly"]); + await openUsageFromItem(page); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); @@ -320,7 +324,7 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag }); try { await gotoAppShell(page); - await openUsage(page); + await openUsageFromIcon(page); await expect( page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); @@ -366,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => { try { await page.setViewportSize({ width: 390, height: 844 }); await gotoAppShell(page); - await openUsage(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); const card = page.getByTestId("usage-report-login-journey:account"); await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); @@ -413,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) { await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expect(usageItem(page)).toBeVisible(); - await usageItem(page).click(); + await openUsageFromItem(page); await qaScreenshot(page, `codex-${shape}-card-and-summary`); await expectCodexReportedWindows(page, shape); await qaScreenshot(page, `codex-${shape}-pins`); @@ -439,7 +444,9 @@ for (const theme of ["light", "dark"] as const) { lists: [() => claudeAndCodexReports()], }); await page.setViewportSize(viewport); - await page.goto("/usage"); + await gotoAppShell(page); + if (size === "compact") await openCompactSidebar(page); + await openUsageFromIcon(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); const settings = page.getByTestId("usage-options-menu"); @@ -501,7 +508,7 @@ test("an account with all logins failing shows each harness and remedy, then onl ]); try { await gotoAppShell(page); - await openUsage(page); + await openUsageFromIcon(page); await expectLoginErrors(page, [ "Codex: Usage API returned 500. Try again.", "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 2b8d982de7..1b509f5640 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -7,7 +7,11 @@ import { installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; -import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; +import { + openUsageFromIcon, + refreshAllUsage, + showUsageAs, +} from "../support/helpers/usage-sidebar-item"; const ICON = ''; @@ -98,7 +102,8 @@ test.describe("usage settings", () => { // The shared percentages setting applies to the host section. await expect(page.getByTestId("usage-options-menu")).toBeVisible(); const hostUsageUrl = page.url(); - await page.goto("/usage"); + await gotoAppShell(page); + await openUsageFromIcon(page); await showUsageAs(page, "remaining"); await page.goto(hostUsageUrl); await expect(card.getByText("30% left")).toBeVisible(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index e43ccdc4a3..fb738b3cb5 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -2,6 +2,11 @@ import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher"; +import { + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; import { @@ -14,28 +19,29 @@ import { import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, - closeUsageSheet, + closeUsage, + closeUsageFromBackdrop, closeUsageOptions, - openUsageSheetFromIcon, expectNoUsageItem, - expectOnUsageScreen, expectPinnedUsage, expectSummaryInSidebar, expectUnpinnableRows, - leaveUsageScreen, openCompactSidebar, - openUsageScreenFromIcon, + openUsageFromIcon, + openUsageFromItem, pinRow, + scrollUsage, setSummaryInSidebar, openUsageOptions, showUsageAs, togglePin, usageItem, - usageSheet, + usageModal, installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; +import { createTempGitRepo } from "../support/helpers/workspace"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -75,49 +81,96 @@ test.describe("Usage item", () => { await expectNoUsageItem(page); await test.step("the icon opens a sheet without leaving the current screen", async () => { - await openUsageSheetFromIcon(page); - await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible(); + await openUsageFromIcon(page); + await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible(); await expect(page).toHaveURL(startingUrl); await qaScreenshot(page, "compact-usage-icon-sheet"); await setSummaryInSidebar(page, true); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); - await closeUsageSheet(page); + await closeUsageFromBackdrop(page); }); await test.step("the summary opens the same sheet", async () => { - await usageItem(page).click(); - await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible(); + await openUsageFromItem(page); + await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible(); await expect(page).toHaveURL(startingUrl); await qaScreenshot(page, "compact-usage-summary-sheet"); - await closeUsageSheet(page); + await closeUsageFromBackdrop(page); }); }); - test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + + test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({ page, }) => { - test.setTimeout(120_000); - const source = await installTallUsageSource(); + const repo = await createTempGitRepo("usage-modal-"); + const client = await connectNewWorkspaceDaemonClient(); try { - await page.setViewportSize(COMPACT); + const project = await openProjectViaDaemon(client, repo.path); + await page.setViewportSize(WIDE); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); - await gotoAppShell(page); - await openCompactSidebar(page); - await expect(usageItem(page)).toBeInViewport(); - await usageItem(page).click(); - const content = page.getByTestId("sidebar-usage-sheet-content"); - await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); - await waitForSettledPosition(content); - const bounds = (await content.boundingBox())!; - expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); - await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); - await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); - await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + await gotoWorkspace(page, project.workspaceId); + const workspaceUrl = page.url(); + + await test.step("the summary opens a dialog over the workspace", async () => { + await openUsageFromItem(page); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Claude", { exact: true })).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Refresh all", exact: true }), + ).toBeVisible(); + await expect(page).toHaveURL(workspaceUrl); + await qaScreenshot(page, "desktop-usage-dialog"); + }); + + await test.step("Close returns to the same workspace", async () => { + await closeUsage(page); + await expect(page).toHaveURL(workspaceUrl); + await waitForTabBar(page); + }); + + await test.step("the icon opens it again, and Escape closes it", async () => { + await openUsageFromIcon(page); + await page.keyboard.press("Escape"); + await expect(usageModal(page)).toHaveCount(0); + await expect(page).toHaveURL(workspaceUrl); + }); } finally { - await source.cleanup(); + await client.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); } }); + + const layouts = { + desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} }, + compact: { viewport: COMPACT, showSidebar: openCompactSidebar }, + }; + for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) { + test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(viewport); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await showSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await openUsageFromItem(page); + const content = usageModal(page); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-top`); + await scrollUsage(page, 5_000); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-bottom`); + await scrollUsage(page, -5_000); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + } finally { + await source.cleanup(); + } + }); + } test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -146,8 +199,7 @@ test.describe("Usage item", () => { await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); await qaScreenshot(page, "default-pins-card"); await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); @@ -172,17 +224,13 @@ test.describe("Usage item", () => { await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible(); - await qaScreenshot(page, "usage-screen-header", { - kind: "element", - locator: page.getByTestId("page-title").locator(".."), - }); await qaScreenshot(page, "usage-card-refresh", { kind: "element", locator: screen.getByTestId("usage-report-claude:default"), }); }); - await test.step("remaining flips the Usage item and the Usage screen", async () => { + await test.step("remaining flips the Usage item and the Usage modal", async () => { await showUsageAs(page, "remaining"); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); @@ -194,23 +242,15 @@ test.describe("Usage item", () => { ).toBeVisible(); }); - await test.step("on desktop the Usage item opens the Usage screen", async () => { - await gotoAppShell(page); - await expect(page).not.toHaveURL(/\/usage$/); - await usageItem(page).click(); - await expectOnUsageScreen(page); - await expect(page.getByTestId("usage-expanded")).toHaveCount(0); - }); - await test.step("on a phone the Usage item opens a bottom sheet", async () => { + await closeUsage(page); await page.setViewportSize(COMPACT); - await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); - await usageItem(page).click(); - const sheet = usageSheet(page); + await openUsageFromItem(page); + const sheet = usageModal(page); await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport(); await expect(sheet.getByText("88% left")).toBeVisible(); await expect(pinRow(sheet, "Claude", "Session")).toBeChecked(); @@ -231,25 +271,20 @@ test.describe("Usage item", () => { await closeUsageOptions(page); await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0); await expect(sheet).toBeVisible(); - await expect(page).not.toHaveURL(/\/usage$/); await waitForSettledPosition(sheet); const sheetBox = (await sheet.boundingBox())!; - // The Usage sheet reserves the top 20% as its backdrop. + // The sheet leaves the screen above it as its backdrop. expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); await qaScreenshot(page, "compact-sheet"); - await qaScreenshot(page, "phase7-compact-sheet"); - // Tap the backdrop above the sheet. - await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2); - await expect(sheet).toHaveCount(0); + await closeUsageFromBackdrop(page); await page.setViewportSize(WIDE); - await usageItem(page).click(); - await expectOnUsageScreen(page); }); await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageFromItem(page); await openUsageOptions(page); await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( "aria-selected", @@ -260,13 +295,15 @@ test.describe("Usage item", () => { }); await test.step("the Settings usage section shares the pins and the toggle", async () => { - const usageUrl = page.url(); + const workspaceUrl = page.url(); + await closeUsage(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); const section = page.getByTestId("usage-card"); await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 }); await expect(pinRow(section, "Codex", "Weekly")).toBeChecked(); - await page.goto(usageUrl); + await page.goto(workspaceUrl); + await openUsageFromItem(page); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); }); @@ -275,6 +312,7 @@ test.describe("Usage item", () => { await togglePin(screen, "Codex", "Weekly"); await expectNoUsageItem(page); await page.reload(); + await openUsageFromIcon(page); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); await expectNoUsageItem(page); await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); @@ -282,6 +320,7 @@ test.describe("Usage item", () => { await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); await qaScreenshot(page, "explicit-empty-pins-after-reload"); + await closeUsage(page); }); await test.step("Settings > Sidebar lists the Usage item", async () => { @@ -325,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i await expectNoUsageItem(page); await expectFooterSeparator(page, false); await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" }); - await page.locator('[data-testid="sidebar-usage-icon"]:visible').click(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); }); test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({ @@ -340,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i const claude = screen.getByTestId("usage-report-claude:default"); await test.step("off by default: no Usage item, and rows that do not pin", async () => { - await openUsageScreenFromIcon(page); + await openUsageFromIcon(page); await expectNoUsageItem(page); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default")); await expectSummaryInSidebar(page, false); - await qaScreenshot(page, "usage-screen-summary-off"); + await qaScreenshot(page, "usage-summary-off"); }); await test.step("on: the Usage item and the pins appear", async () => { @@ -355,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); await togglePin(screen, "Claude", "Weekly"); await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); - await qaScreenshot(page, "usage-screen-summary-on"); + await qaScreenshot(page, "usage-summary-on"); }); await test.step("off then on again, after a reload, brings back the saved pins", async () => { @@ -363,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expectNoUsageItem(page); await expectUnpinnableRows(claude); await page.reload(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); await expectSummaryInSidebar(page, false); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); @@ -391,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async ( await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect( screen.getByTestId("usage-report-claude").locator("svg path").first(), @@ -400,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async ( await expect( screen.getByTestId("usage-report-codex").locator("svg path").first(), ).toHaveAttribute("d", codexPath); - await qaScreenshot(page, "released-host-usage-screen"); + await qaScreenshot(page, "released-host-usage-modal"); }); diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 985e7bd7ac..6129f0259e 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -58,10 +58,6 @@ export default function contribute(server) { } } -export async function openUsage(page: Page) { - await page.goto("/usage"); -} - export async function refreshLoginUsage(page: Page) { await page.getByRole("button", { name: "Refresh Claude", exact: true }).click(); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index d21473867e..8cc3c07a00 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; import { waitForSettledPosition } from "./sheet-layout"; -/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +/** Real usage-source plugin; its long report makes the Usage modal scroll. */ export async function installTallUsageSource() { const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); @@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator { return visible(page, "sidebar-usage"); } -/** The compact usage sheet the Usage item opens. */ -export function usageSheet(page: Page): Locator { - return visible(page, "usage-expanded"); -} - -export async function expectOnUsageScreen(page: Page): Promise { - await expect(page).toHaveURL(/\/usage$/); +/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */ +export function usageModal(page: Page): Locator { + return visible(page, "usage-modal-body"); } /** Each pinned window as it reads: its percent and short label, "31% 5h". */ @@ -189,23 +185,45 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -/** The footer's Usage icon, which is there whether or not the Usage item is on. */ -export async function openUsageScreenFromIcon(page: Page): Promise { +/** + * The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the + * sidebar drawer has to be open. + */ +export async function openUsageFromIcon(page: Page): Promise { await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 }); - await expectOnUsageScreen(page); + await expect(usageModal(page)).toBeVisible(); } -/** Both footer entry points open Usage over the current screen on a phone. */ -export async function openUsageSheetFromIcon(page: Page): Promise { - await page.getByRole("button", { name: "Usage", exact: true }).click(); - await expect(usageSheet(page)).toBeVisible(); +/** The footer's Usage item, its summary of pinned windows. */ +export async function openUsageFromItem(page: Page): Promise { + await usageItem(page).click(); + await expect(usageModal(page)).toBeVisible(); } -export async function closeUsageSheet(page: Page): Promise { - const sheet = usageSheet(page); +/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */ +export async function scrollUsage(page: Page, deltaY: number): Promise { + const body = await usageModal(page).boundingBox(); + const viewport = page.viewportSize(); + if (!body || !viewport) throw new Error("Usage must be open before scrolling it."); + // The middle of the part of the body on screen; the body itself runs past the viewport. + const top = Math.max(body.y, 0); + const bottom = Math.min(body.y + body.height, viewport.height); + await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2); + await page.mouse.wheel(0, deltaY); +} + +/** The modal's own Close button. */ +export async function closeUsage(page: Page): Promise { + await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); + await expect(usageModal(page)).toHaveCount(0); +} + +/** A tap on the backdrop above the bottom sheet. */ +export async function closeUsageFromBackdrop(page: Page): Promise { + const sheet = usageModal(page); await waitForSettledPosition(sheet); - const bounds = await sheet.boundingBox(); - if (!bounds) throw new Error("Usage sheet must be visible before closing it."); + const bounds = await visible(page, "usage-modal").boundingBox(); + if (!bounds) throw new Error("Usage must be open before closing it."); await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2); await expect(sheet).toHaveCount(0); } @@ -214,7 +232,7 @@ function summaryInSidebarSwitch(page: Page): Locator { return page.getByRole("switch", { name: "Summary in sidebar", exact: true }); } -/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */ +/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */ export async function setSummaryInSidebar(page: Page, on: boolean): Promise { await openUsageOptions(page); await summaryInSidebarSwitch(page).click(); @@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } - -/** On a phone the Usage screen has a back header instead of the sidebar menu. */ -export async function leaveUsageScreen(page: Page): Promise { - await page.getByRole("button", { name: "Back", exact: true }).first().click(); - await expect(page).not.toHaveURL(/\/usage$/); -} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2eb4e5185a..6e9b506b55 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || - pathname === "/usage" || routeHasKnownHost); return {children}; @@ -896,7 +895,6 @@ function RootStack() { - diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx deleted file mode 100644 index 2928cca3f3..0000000000 --- a/packages/app/src/app/usage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { UsageScreen } from "@/usage"; - -export default function UsageRoute() { - return ( - - - - ); -} diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx index ec13605d08..e57f3cf6ac 100644 --- a/packages/app/src/components/sidebar/sidebar-popover.tsx +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model"; /** * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on - * compact ones. Plugin items and the built-in Usage item open it the same way: + * compact ones. Plugin items open it this way: * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, * and `SidebarPopoverSurface` for the body. */ diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index d8e2ce74d1..020faf61ec 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -65,8 +65,7 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage screen and the - * compact usage sheet. + * One host's usage and the title-row controls that go with it, for the Usage modal. */ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { view: UsageView; diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 450e0a6157..dba21dc316 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -6,7 +6,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import { resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, type UsageHost, type UsageHostChoice, } from "./model"; @@ -35,7 +35,7 @@ export function useUsageHostId(): string | null { } /** - * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick + * The host the Usage modal shows, and the hosts to pick from. A pick * is saved on the device, so the sidebar Usage row and later visits show the same host. */ export function useUsageHostSelection(): { @@ -55,5 +55,5 @@ export function useUsageHostSelection(): { }, [updateSettings], ); - return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; + return { serverId: resolveUsageModalHostId(choice), connectedHosts, select }; } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index c9ae924f10..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,7 +1,6 @@ export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; export { useHostReportsUsage } from "./queries"; -export { UsageScreen } from "./usage-screen"; export { UsageSidebarItem, UsageSidebarRoot, diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 1276157120..ad293bb23e 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -6,7 +6,7 @@ import { replaceReport, resolveUsageRefresh, resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, resolveUsageView, settleReports, upsertReport, @@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => { }); }); -describe("resolveUsageScreenHostId", () => { +describe("resolveUsageModalHostId", () => { it("shows the picked host while it stays connected, even one that cannot report usage", () => { - expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( "old", ); - expect( - resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), - ).toBe("b"); + expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe( + "b", + ); }); it("otherwise shows the host the sidebar row reads", () => { for (const pickedServerId of [null, "a", "offline"]) { for (const activeServerId of ["b", null, "old", "offline"]) { const choice = { pickedServerId, activeServerId, hosts }; - expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice)); + expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice)); } } }); - it("shows the first connected host when none reports usage, so the screen says to update it", () => { + it("shows the first connected host when none reports usage, so the modal says to update it", () => { expect( - resolveUsageScreenHostId({ + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: hosts.slice(0, 2), }), ).toBe("old"); expect( - resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), ).toBeNull(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 2b2e4c54cf..a32da7ea4d 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -176,11 +176,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null { } /** - * The host the Usage screen shows: the picked host while it is connected, even one that cannot - * report usage so the screen says to update it; else the sidebar row's host; else the first + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first * connected host. */ -export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { const connected = choice.hosts.filter((host) => host.isConnected); const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index fc8f28c1c0..d90013d8bf 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,4 +1,3 @@ -import { router } from "expo-router"; import { createContext, Fragment, @@ -17,13 +16,9 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { buildUsageRoute } from "@/utils/host-routes"; -import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences, type UsageDisplay } from "./display"; -import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import { useUsagePreferences } from "./display"; +import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import { UsageMeter } from "./meter"; @@ -35,8 +30,7 @@ import { type PinnedUsageLayout, type PinnedUsageSource, } from "./pinned"; -import type { UsageHost } from "./model"; -import { UsageBody } from "./usage-section"; +import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ function useUsageSummary(): readonly PinnedUsageSource[] { @@ -52,8 +46,8 @@ export function useHasUsageSummary(): boolean { /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing - * while no summary window has data, since the footer's Usage icon already opens the screen. - * Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead. + * while no summary window has data, since the footer's Usage icon already opens Usage. + * Pressing it opens the Usage modal. */ export function UsageSidebarItem() { const { t } = useTranslation(); @@ -66,30 +60,25 @@ export function UsageSidebarItem() { const OpenSidebarUsageContext = createContext<(() => void) | null>(null); -/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */ +/** + * One owner for the footer icon and summary: both open the Usage modal over the current screen. + * The modal mounts on first open and unmounts once it has finished closing, so its host's reports + * load only while it is shown. + */ export function UsageSidebarRoot({ children }: { children: ReactNode }) { - const { t } = useTranslation(); - const { display } = useUsagePreferences(); - const isCompact = useIsCompactFormFactor(); const [open, setOpen] = useState(false); - const [sheetMounted, setSheetMounted] = useState(false); + const [mounted, setMounted] = useState(false); const openUsage = useCallback(() => { - if (isCompact) { - setSheetMounted(true); - setOpen(true); - return; - } - router.push(buildUsageRoute()); - }, [isCompact]); + setMounted(true); + setOpen(true); + }, []); + const closeUsage = useCallback(() => setOpen(false), []); + const unmountUsage = useCallback(() => setMounted(false), []); return ( - - {children} - {isCompact && sheetMounted ? ( - - ) : null} - + {children} + {mounted ? : null} ); } @@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void { return openUsage; } -/** - * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls - * in its title row. - */ -function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) return null; - return ( - - ); -} - -function HostUsageSheet({ - title, - serverId, - hosts, - onSelectHost, - display, -}: { - title: string; - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; - display: UsageDisplay; -}) { - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} - function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { const windows = sources.flatMap((source) => source.windows); return `${label}: ${windows.map((window) => window.label).join(", ")}`; @@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({ windowLabel: { color: theme.colors.foregroundMuted, }, - sheetBody: { - padding: theme.spacing[3], - gap: theme.spacing[3], - }, })); diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useHostUsageWithControls } from "./controls"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; + +interface UsageModalProps { + visible: boolean; + onClose: () => void; + onDismiss: () => void; +} + +/** + * Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It + * shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its + * title row. + */ +export function UsageModal(props: UsageModalProps) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +function HostUsageModal({ + serverId, + hosts, + onSelectHost, + ...props +}: UsageModalProps & { + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; +}) { + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + + + + ); +} + +function UsageModalFrame({ + visible, + onClose, + onDismiss, + actions, + children, +}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + const title = t(builtinSidebarNavLabelKey("usage")); + const header = useMemo(() => ({ title, actions }), [actions, title]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx deleted file mode 100644 index 559d15095e..0000000000 --- a/packages/app/src/usage/usage-screen.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useIsFocused } from "@react-navigation/native"; -import { router } from "expo-router"; -import { useMemo, type ReactNode } from "react"; -import { View } from "react-native"; -import { PageLayout } from "@/components/page-layout"; -import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; -import { useUsagePreferences } from "./display"; -import { useUsageHostSelection } from "./hosts"; -import type { UsageHost } from "./model"; -import { UsageBody, UsageMessage } from "./usage-section"; - -// The screen is reachable by URL, so there may be no history to go back to. -function leaveUsage(): void { - if (router.canGoBack()) { - router.back(); - return; - } - router.replace("/"); -} - -export function UsageScreen() { - const isFocused = useIsFocused(); - if (!isFocused) return {null}; - return ; -} - -function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { - return ( - - {children} - - ); -} - -function FocusedUsageScreen() { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) { - return ( - - - - ); - } - return ( - - ); -} - -function HostUsage({ - serverId, - hosts, - onSelectHost, -}: { - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; -}) { - const { display } = useUsagePreferences(); - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 1dc3ff48bb..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,10 +428,6 @@ export function buildSchedulesRoute() { return "/schedules" as const; } -export function buildUsageRoute() { - return "/usage" as const; -} - export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 086f910599..da146de78d 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -411,7 +411,7 @@ account metadata is unavailable. Labels can name accounts without becoming their Return logins in preference order and set `harness` to the owning harness's display name, such as Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. -The host-wide Usage screen groups logins with the same key into one account card and tries them +The host-wide Usage modal groups logins with the same key into one account card and tries them concurrently, preferring the discovery order when selecting a result. If any login returns `available`, it shows usage with no errors. If all fail, it shows every login's error on a separate line with its harness label and its own remedy. From c301d6e8aabef51b4c02234f14b4ebe36a4ba25a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:03:50 +0200 Subject: [PATCH 089/281] Reinstall local speech models whose extraction was interrupted (#6210) * fix(speech): publish models only after extraction completes * fix(speech): reinstall local speech models whose extraction was interrupted The daemon decided whether a local speech model was installed with its own check in speech-runtime.ts: every required file exists and is non-empty. A model whose extraction was stopped part-way passed that check, so the daemon never called the downloader again and dictation aborted on the truncated file on every start. The models module now owns "is this model installed", and the runtime asks it. A model whose archive is still in .downloads did not finish installing, since the archive is removed only after the model is in place, so it is reinstalled from that archive. Extraction goes to a directory beside the archive and moves into place once complete. Co-authored-by: 3ae3ae * fix(speech): remove the model archive before moving the model into place * docs(speech): describe when the model archive is removed --------- Co-authored-by: 3ae3ae Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../speech/providers/local/models.test.ts | 70 ++++++++++++ .../server/speech/providers/local/models.ts | 16 ++- .../local/sherpa/model-downloader.test.ts | 74 ++++++++++++- .../local/sherpa/model-downloader.ts | 101 ++++++++++++------ .../src/server/speech/speech-runtime.test.ts | 3 +- .../src/server/speech/speech-runtime.ts | 60 ++--------- 6 files changed, 236 insertions(+), 88 deletions(-) create mode 100644 packages/server/src/server/speech/providers/local/models.test.ts diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 993c959b4d..bc2e462a53 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 8ce21f5e01..cdd286389f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise => { From a700d7875c7768eeaf99f9c13eecf7cde8e07bb3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:06 +0200 Subject: [PATCH 090/281] Stop the provider when archiving an ACP agent that never answers close (#6213) * fix(acp): terminate the provider when close requests never return Archive and delete wait on ACPAgentSession.close(). cancel() and unstable_closeSession() had no timeout. The ACP SDK does not reject a pending request when the provider never answers, so close() never reached terminateProcess. The agent was already marked archived and the provider process stayed alive. Both calls now use the same 2s withTimeout as probe close. The existing catch blocks still continue into terminateProcess. Example: - Input: archive an ACP session whose cancel and closeSession never settle - Before: close() waits forever and the provider process stays alive - After: both calls time out and terminateProcess runs * test(acp): archive close terminates a real provider that never answers session/close Spawn a real ACP provider process over stdio that advertises session close and never replies to it, then close the session the way archive does. Without the request bound, close() never returns and the provider keeps running. Co-authored-by: Ouyang Xingyuan * test(acp): move the close wait and provider cleanup into helpers Clear the race timer once close settles, and dispose the provider process and temp directory in afterEach. --------- Co-authored-by: Ouyang Xingyuan Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/acp-agent.test.ts | 152 +++++++++++++++++- .../src/server/agent/providers/acp-agent.ts | 16 +- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index f847745d69..95d7e25a21 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,8 +1,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, @@ -3436,6 +3438,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3502,6 +3506,38 @@ describe("ACPAgentSession close() tree-kill", () => { expect(child.kill).not.toHaveBeenCalled(); }); + test("close() terminates the provider when cancel and closeSession never settle", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const session = createSession({ terminateProcess: terminator.terminate }); + const child = createTerminalChildStub(); + const cancel = vi.fn(() => new Promise(() => undefined)); + const unstableCloseSession = vi.fn(() => new Promise(() => undefined)); + const internals = asInternals(session); + internals.child = child; + internals.sessionId = "session-1"; + internals.activeForegroundTurnId = "turn-1"; + internals.agentCapabilities = { sessionCapabilities: { close: {} } }; + internals.connection = { cancel, unstable_closeSession: unstableCloseSession }; + + let settled = false; + const closing = (async () => { + await session.close(); + settled = true; + })(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(true); + expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(unstableCloseSession).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(terminator.terminated).toContain(child); + await closing; + } finally { + vi.useRealTimers(); + } + }); + test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); const session = createSession({ terminateProcess: terminator.terminate }); @@ -4220,3 +4256,117 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); }); + +const SILENT_CLOSE_ACP_AGENT = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +writeFileSync(process.env.ACP_PID_FILE, String(process.pid)); +new AgentSideConnection( + () => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }; + }, + async newSession() { + return { sessionId: "silent-close-session" }; + }, + async authenticate() {}, + async cancel() {}, + unstable_closeSession() { + return new Promise(() => {}); + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + clearTimeout(timer); + } +} + +interface SilentCloseProvider { + session: ACPAgentSession; + readPid(): Promise; + dispose(): Promise; +} + +async function startSilentCloseProvider(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-silent-close-")); + const agentScript = path.join(dir, "agent.mjs"); + const pidFile = path.join(dir, "agent.pid"); + await writeFile(agentScript, SILENT_CLOSE_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "silent-close-acp", cwd: dir }, + { + provider: "silent-close-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ACP_PID_FILE: pidFile, + }, + }, + ); + const readPid = async () => Number(await readFile(pidFile, "utf8")); + return { + session, + readPid, + async dispose() { + const pid = await readPid().catch(() => null); + if (pid !== null && isProcessAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("ACPAgentSession close() with an unresponsive provider", () => { + let provider: SilentCloseProvider | null = null; + + afterEach(async () => { + await provider?.dispose(); + provider = null; + }); + + test("terminates a provider that never answers session/close", async () => { + provider = await startSilentCloseProvider(); + await provider.session.initializeNewSession(); + const pid = await provider.readPid(); + expect(isProcessAlive(pid)).toBe(true); + + expect(await settlesWithin(provider.session.close(), 8_000)).toBe(true); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 1b47ccaccb..a647deb549 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -301,6 +301,10 @@ export function buildACPClientCapabilities( const PROBE_ENV: Record = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; @@ -2476,13 +2480,21 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.connection && this.sessionId) { try { if (this.activeForegroundTurnId) { - await this.connection.cancel({ sessionId: this.sessionId }); + await withTimeout( + this.connection.cancel({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP cancel timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch {} try { if (this.agentCapabilities?.sessionCapabilities?.close) { - await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + await withTimeout( + this.connection.unstable_closeSession({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP closeSession timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch (error) { this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); From f5c55dfc551df42474d45f245aaa6ce3f88169b5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:25:59 +0200 Subject: [PATCH 091/281] fix(omp): reopened chats show a compaction row instead of "Unsupported history record" (#6215) * fix(omp): reopened chats show a compaction row instead of "Unsupported history record" OMP stores a compaction as a top-level session entry. History replay sent it to the unknown-record fallback, so after a daemon restart the chat showed a "Custom message" row reading "[compaction] Unsupported history record". Replay now maps the entry to a completed compaction row. Closes #6206 * test(omp): assert on the replayed custom-message rows directly * test(omp): match only the unsupported-record row after restart --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../providers/omp/history-mapper.test.ts | 58 ++++++++++++++++++ .../src/server/agent/providers/omp/history.ts | 32 ++++++++-- .../server/daemon-e2e/omp.real.e2e.test.ts | 59 +++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 61e6d0e73d..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -829,6 +829,64 @@ describe("OMP history mapper", () => { ]); }); + test("replays a compaction entry as a completed compaction row", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Before." }], + responseId: "resp-1", + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "answer-1", + timestamp: "2026-10-05T04:31:19.537Z", + summary: "## Goal\nSummary", + shortSummary: "Summary", + firstKeptEntryId: "answer-1", + tokensBefore: 77611, + tokensAfter: 37526, + method: "snapcompact", + details: { readFiles: [], modifiedFiles: [] }, + fromExtension: false, + }, + { + type: "message", + id: "answer-2", + parentId: "compaction-1", + message: { + role: "assistant", + content: [{ type: "text", text: "After." }], + responseId: "resp-2", + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Before.", messageId: "resp-1" }, + { type: "compaction", status: "completed", preTokens: 77611 }, + { type: "assistant_message", text: "After.", messageId: "resp-2" }, + ]); + expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" }); + }); + test("maps omp 18.1 custom_message entries like live custom messages", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-")); const sessionFile = join(dir, "session.jsonl"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 2e86b9670c..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -20,6 +20,10 @@ interface OmpSessionEntry { [key: string]: unknown; } +type OmpReplayEntry = + | { entry: OmpSessionEntry; message: OmpAgentMessage } + | { entry: OmpSessionEntry; item: AgentTimelineItem }; + export async function readOmpHistoryTodoState( sessionFile: string, ): Promise { @@ -90,13 +94,17 @@ export async function* streamOmpHistory(input: { throw error; } const messages: OmpAgentMessage[] = []; - const messageEntries: OmpSessionEntry[] = []; + const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; for (const entry of entries) { + if (entry.type === "compaction") { + replayEntries.push({ entry, item: mapCompactionEntry(entry) }); + continue; + } const mapped = mapEntryMessage(entry); if (!mapped) continue; messages.push(mapped); - messageEntries.push(entry); + replayEntries.push({ entry, message: mapped }); if (mapped.role === "user" && entry.id) { userEntries.push({ id: entry.id, text: textOf(mapped.content) }); } @@ -107,9 +115,13 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); - for (let index = 0; index < messages.length; index += 1) { - const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp); - for (const event of mapper.mapMessages([messages[index]!])) { + for (const replayEntry of replayEntries) { + const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); + const events: AgentStreamEvent[] = + "item" in replayEntry + ? [{ type: "timeline", provider: input.provider, item: replayEntry.item }] + : mapper.mapMessages([replayEntry.message]); + for (const event of events) { yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event; } } @@ -393,6 +405,16 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { return visibleFallback(entry.type, entry); } +// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row. +// The entry does not record whether a manual /compact or auto-compaction produced it. +function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem { + return { + type: "compaction", + status: "completed", + ...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}), + }; +} + // omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level // custom_message entries without a message object; replay them as live custom messages function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index 510e8b384d..7149b16864 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -131,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise entry.item); } +function compactionFiller(): string { + const teas = ["assam", "darjeeling", "sencha", "oolong"]; + return Array.from( + { length: 1200 }, + (_, index) => + `Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`, + ).join("\n"); +} + function latestTools(items: AgentTimelineItem[]) { const latest = new Map>(); for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item); @@ -510,6 +519,56 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "a compaction shows as a compaction row after a daemon restart", + async () => { + const harness = await createHarness(); + let restarted: TestPaseoDaemon | null = null; + let restartedClient: DaemonClient | null = null; + try { + const agent = await createAgent(harness, "compact-restart"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND"); + await harness.client.sendMessage(agent.id, "/compact"); + await harness.client.waitForFinish(agent.id, TIMEOUT_MS); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD"); + + await harness.client.close(); + await harness.daemon.close(); + restarted = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })), + providerOverrides: { omp: { enabled: true } }, + logger: pino({ level: "silent" }), + paseoHomeRoot: harness.paseoHomeRoot, + staticDir: harness.staticDir, + cleanup: false, + }); + restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` }); + await restartedClient.connect(); + await restartedClient.fetchAgents({ subscribe: {} }); + const replayed = await timeline(restartedClient, agent.id); + + expect( + latestTools(replayed) + .map(toolResult) + .filter((text) => text.includes("Unsupported history record")), + ).toEqual([]); + expect(replayed.filter((item) => item.type === "compaction")).toEqual([ + expect.objectContaining({ type: "compaction", status: "completed" }), + ]); + } finally { + await restartedClient?.close().catch(() => undefined); + await restarted?.close().catch(() => undefined); + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "native branch rewinds conversation history and keeps the session usable", async () => { From 396a2bc0b279f868a6c439ee1fe3b226c52ff707 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 15:34:15 +0100 Subject: [PATCH 092/281] fix(website): count a plugin install once per IP per hour (#6230) Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent. --- packages/website/README.md | 22 ++++ packages/website/src/plugins/installs.ts | 20 ++++ packages/website/src/plugins/published.ts | 9 +- packages/website/src/plugins/registry.test.ts | 111 +++++++++++++++++- 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 packages/website/README.md diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve//` or +`/plugins//.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/src/plugins/installs.ts b/packages/website/src/plugins/installs.ts index 01846e6797..78b4294639 100644 --- a/packages/website/src/plugins/installs.ts +++ b/packages/website/src/plugins/installs.ts @@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const; const totalKey = (id: string) => `plugin-installs:${id}`; const dailyKey = (id: string) => `plugin-installs-daily:${id}`; +export async function recordClientInstall({ + cache, + id, + ip, +}: { + cache: KVNamespace; + id: string; + ip: string | null; +}): Promise { + if (!ip) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + const hash = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const key = `plugin-installs-client:${id}:${hash}`; + if ((await cache.get(key)) !== null) return; + await cache.put(key, "1", { expirationTtl: 3600 }); + await recordInstall(cache, id, new Date()); +} + export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise { const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); await Promise.all([ diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index 4d6826f11e..ebf7e5256b 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -5,7 +5,7 @@ import { } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; import { CATEGORIES } from "./categories"; -import { type InstallCounts, readInstallCounts, recordInstall } from "./installs"; +import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs"; export interface RegistryEnvironment { PLUGINS_REGISTRY_URL?: string; WEBSITE_CACHE?: KVNamespace; @@ -111,7 +111,12 @@ export async function handlePluginRegistryRequest( url.searchParams.get("intent") === "install" ) { const cache = context.cache; - if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined)); + if (cache) + context.waitUntil( + recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch( + () => undefined, + ), + ); } return Response.json(plugin); } diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index d5244fcb3f..bd2dce67ec 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; import { @@ -10,7 +11,7 @@ import { searchPlugins, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; -import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; const plugin: Plugin = { id: "acme/example", name: "Example", @@ -94,6 +95,81 @@ describe("plugin registry", () => { Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")), ).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]); }); + it("counts repeated install reports from the same IP once per hour", async () => { + let now = Date.now(); + const cache = memoryKv(() => now); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + now += 3_599_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 1 }, + }); + now += 1_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + }); + }); + it("counts different IPs and plugins independently, including query install intent", async () => { + const cache = memoryKv(); + const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, first); + await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other" }); + await resolvePlugin(cache, first, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true }); + expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + "acme/other": { week: 1, month: 1, all: 1 }, + }); + const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex"); + expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1"); + expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1"); + expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1"); + }); + it("does not count without a Cloudflare IP or install intent", async () => { + const cache = memoryKv(); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" }); + await resolvePlugin(cache, {}, { query: "?intent=install" }); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" }); + await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 0, month: 0, all: 0 }, + }); + expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]); + }); + it("preserves historical totals and the installs endpoint response", async () => { + const cache = memoryKv(); + await cache.put(`plugin-installs:${plugin.id}`, "40"); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + await resolvePlugin(cache, headers); + const base = "https://registry.example.test"; + await cache.put( + `plugins:index:v1:${base}`, + JSON.stringify({ + fetchedAt: Date.now(), + value: { + schemaVersion: 1, + registry: { name: "Internal", url: base }, + categories: [], + plugins: [plugin], + generatedAt: "2026-10-03", + }, + }), + ); + const response = await handlePluginRegistryRequest( + new Request("https://paseo.sh/api/plugins/installs"), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: () => undefined }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ [plugin.id]: 41 }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 41 }, + }); + }); it("searches name, description, ID, and author, ignoring case", () => { const other = { ...plugin, @@ -125,16 +201,43 @@ describe("plugin registry", () => { }); /** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */ -function memoryKv(): KVNamespace { +function memoryKv(now = Date.now): KVNamespace & { values: Map } { const values = new Map(); + const expiresAt = new Map(); const kv = { + values, get: async (key: string, options?: { type?: string }) => { + if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key); const value = values.get(key) ?? null; return options?.type === "json" && value !== null ? JSON.parse(value) : value; }, - put: async (key: string, value: string) => { + put: async (key: string, value: string, options?: { expirationTtl?: number }) => { values.set(key, value); + expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity); }, }; - return kv as unknown as KVNamespace; + return kv as unknown as KVNamespace & { values: Map }; +} + +async function resolvePlugin( + cache: KVNamespace, + headers: HeadersInit, + { id = plugin.id, query = "", staticDetail = false } = {}, +): Promise { + const base = "https://registry.example.test"; + const detail = { ...plugin, id, readme: "# Example" }; + await cache.put( + `plugins:detail:v1:${base}:${id}`, + JSON.stringify({ fetchedAt: Date.now(), value: detail }), + ); + const pending: Promise[] = []; + const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`; + const response = await handlePluginRegistryRequest( + new Request(`https://paseo.sh${path}${query}`, { headers }), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: (promise) => pending.push(promise) }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(detail); + await Promise.all(pending); } From 06bde1e57aabfbc37b20d52af0487e7c254c8155 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 15:58:26 +0100 Subject: [PATCH 093/281] Scaffold author overviews for plugin listings (#6226) * feat(plugins): scaffold author listing overviews * docs(plugins): refine the author overview contract * docs(plugins): require repository overviews for registry listings * docs(plugins): keep listing guidance focused on authors --- .../cli/src/commands/plugin/scaffold.test.ts | 45 ++++++++++++++++++- packages/cli/src/commands/plugin/scaffold.ts | 30 +++++++++++++ public-docs/plugins/publishing.md | 41 +++++++++++++++++ skills/paseo-plugin/SKILL.md | 43 ++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index df0c13295b..c69ca4c197 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise { @@ -28,6 +29,47 @@ afterEach(async () => { }); describe("plugin scaffold", () => { + it("creates an author overview beside the manifest", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + await scaffoldPluginDirectory(directory); + + const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); + const comments = overview.match(//g) ?? []; + expect(comments).toHaveLength(1); + expect(overview.replace(//g, "").trim()).toBe(""); + expect(overview).toContain("required to list"); + expect(overview).toContain("README"); + expect(overview).toContain("choosing a plugin in Paseo"); + expect(overview).toContain("Length follows complexity"); + expect(overview).toContain("1. Describe what your plugin is and does"); + expect(overview).toContain("2. Explain how it works only when it is not obvious"); + expect(overview).toContain("3. Explain setup when needed"); + expect(overview).toContain("4. Explain capabilities and settings"); + expect(overview).toContain("permissions"); + expect(overview).toContain("known limits"); + expect(overview).not.toContain("This plugin entry was imported"); + const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(packageJson.files).toContain("OVERVIEW.md"); + expect(await readdir(directory)).toContain("paseo-plugin.json"); + }); + + it("includes the author overview in the packed npm artifact", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "packed-plugin"); + await scaffoldPluginDirectory(directory); + + const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { + cwd: directory, + }); + const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; + expect(artifact.files.map((file) => file.path)).toEqual( + expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), + ); + }); + it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, @@ -60,6 +102,7 @@ describe("plugin scaffold", () => { version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 448aa956fa..f0f1fe3b93 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -3,6 +3,34 @@ import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { resolveCliVersion } from "../../version.js"; +const OVERVIEW = ` +`; + const TSCONFIG = { compilerOptions: { target: "ES2020", @@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory( version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -179,6 +208,7 @@ export async function scaffoldPluginDirectory( "paseo-plugin.json", `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, ], + ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/public-docs/plugins/publishing.md b/public-docs/plugins/publishing.md index 0e7f2f89cf..a021bda644 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -56,6 +56,7 @@ The scaffold includes this `files` list in `package.json`: { "files": [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -136,6 +137,46 @@ In the app, enter only the source identifier. ::: +## Your listing page + +Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. +It helps someone decide whether to install your plugin; the install command is already at +the top of that page. A README assumes a GitHub audience and carries installation +instructions, technical details, and badges. Long, AI-generated READMEs make people read +past that material to understand what a plugin does. + +`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside +`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it +relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm +package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment +with useful facts before publishing. + +The repository overview takes precedence over a registry import stopgap. Online validation +fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records +that already carry `plugins//.md` in the registry repository. Every version bump +requires a repository overview and removes the stopgap in the same PR. A bump without the +repository overview fails validation. Neither README files nor a `paseo-listing.json` +`readme` override supplies the overview. + +Author overviews and registry stopgaps follow the same content contract, in this order. +Choose headings only when they help; length follows complexity. A theme needs one paragraph. + +1. Explain what the plugin is and does in plain terms first. +2. Explain how it works only when that is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other + plugins. Include applicable daemon version and operating system requirements. Setup + guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, what the + plugin reads or sends and where, permissions, and known limits. + +Use sentence case and plain factual language, with no em dashes. Omit installation commands, +badges, changelog, contributing or license sections, marketing, and unsupported claims. +Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields, +or lists of absent features. Keep only what helps someone choose the plugin. + +If your plugin is an unchanged imported record, you can propose an author-written overview +to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`. + ## Share through GitHub or Git Push the plugin project to a repository. Users can install it with: diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 6fc8c90f87..f229bf7a3a 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -64,6 +64,7 @@ The generated project contains: ```text my-plugin/ paseo-plugin.json + OVERVIEW.md package.json tsconfig.json index.client.tsx @@ -638,6 +639,48 @@ Do not edit a local config when the target is a remote daemon. Perform the edit When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host. +## Publishing a listing + +Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. +It helps someone decide whether to install your plugin; the install command is already at +the top of that page. A README assumes a GitHub audience and carries installation +instructions, technical details, and badges. Long, AI-generated READMEs make people read +past that material to understand what a plugin does. + +`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside +`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it +relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm +package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment +with useful facts before publishing. + +The repository overview takes precedence over a registry import stopgap. Online validation +fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records +that already carry `plugins//.md` in the registry repository. Every version bump +requires a repository overview and removes the stopgap in the same PR. A bump without the +repository overview fails validation. Neither README files nor a `paseo-listing.json` +`readme` override supplies the overview. + +Author overviews and registry stopgaps follow the same content contract, in this order. +Choose headings only when they help; length follows complexity. A theme needs one paragraph. + +1. Explain what the plugin is and does in plain terms first. +2. Explain how it works only when that is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other + plugins. Include applicable daemon version and operating system requirements. Setup + guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, what the + plugin reads or sends and where, permissions, and known limits. + +Use sentence case and plain factual language, with no em dashes. Omit installation commands, +badges, changelog, contributing or license sections, marketing, and unsupported claims. +Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields, +or lists of absent features. Keep only what helps someone choose the plugin. + +If your plugin is an unchanged imported record, you can propose an author-written overview +to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`. + +See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page). + ## Typecheck and manage When editing a plugin, typecheck its source before install or reload: From 56d568074210f2a16c5ca7ef7578169e882612df Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:03:17 +0200 Subject: [PATCH 094/281] fix(highlight): keep Astro expressions colored through nested template strings (#6219) An Astro { } expression containing a template string inside ${} closed at the first } in that nested string, leaving the rest of the expression uncolored. Astro and Vue now share one scanner for embedded JavaScript, which already skips template substitutions. Closes #6209 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/__tests__/highlighter.test.ts | 13 +++ packages/highlight/src/astro/parser.ts | 80 +------------- packages/highlight/src/js-scanner.ts | 100 ++++++++++++++++++ packages/highlight/src/vue/parser.ts | 99 +---------------- 4 files changed, 116 insertions(+), 176 deletions(-) create mode 100644 packages/highlight/src/js-scanner.ts diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index 386b0c6364..c245d42da1 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -144,6 +144,19 @@ const title = "Hello"; ); }); + it("keeps an Astro expression open through nested template strings", () => { + const code = "

{`a ${`}`} b`}

"; + + const tokens = highlightCode(code, "Nested.astro").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights Vue SFCs across script, template, and style", () => { const code = [ ''; + const upstream = imageFetch( + new Response("unsupported", { status: 415 }), + imageResponse(svg, { "content-type": "image/svg+xml" }), + ); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(await response.text()).toBe(svg); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it.each([ + [592, source, "image/avif,image/webp", "avif"], + [592, source, "image/avif;q=0,image/webp", "webp"], + [592, source, "image/avif;q=0,image/webp;q=0", undefined], + [ + 1184, + "https://raw.githubusercontent.com/acme/example/abc123/preview.png", + "image/webp", + "webp", + ], + [592, source, "image/png,image/*;q=0.8", undefined], + ] as const)( + "transforms a registered screenshot at %s from %s for %s", + async (width, screenshot, accept, format) => { + const upstream = imageFetch(imageResponse("thumbnail")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(screenshot, width, plugin.id, accept), + [{ ...plugin, screenshots: [screenshot] }], + upstream.fetchImage, + ); + expect(upstream.calls).toEqual([ + { + input: screenshot, + init: { + cf: { image: { width, fit: "scale-down", format, quality: 80 } }, + }, + }, + ]); + expect(response.status).toBe(200); + expect(await response.text()).toBe("thumbnail"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("vary")).toBe("Accept"); + }, + ); + + it.each([ + ["Cloudflare error", () => new Response("error 9401", { status: 403 })], + [ + "non-image success", + () => new Response("unavailable", { headers: { "content-type": "text/html" } }), + ], + ["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })], + ["network exception", () => new Error("Transform unavailable")], + ] as const)("proxies the original with immutable caching on %s", async (_, failure) => { + const upstream = imageFetch(failure(), imageResponse("original")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(upstream.calls.map((call) => call.input)).toEqual([source, source]); + expect(upstream.calls[1].init).toBeUndefined(); + expect(response.status).toBe(200); + expect(await response.text()).toBe("original"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + }); + + it.each([ + ["arbitrary width", thumbnailRequest(source, 593), [plugin], 400], + ["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404], + ["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404], + [ + "missing plugin ID", + new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`), + [plugin], + 404, + ], + ["removed plugin", thumbnailRequest(), [], 404], + ["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400], + ["malformed URL", thumbnailRequest("not a URL"), [plugin], 400], + ...[ + "https://example.com/preview.png", + "https://cdn.jsdelivr.net.evil.test/preview.png", + "http://cdn.jsdelivr.net/preview.png", + "https://user:pass@cdn.jsdelivr.net/preview.png", + "https://cdn.jsdelivr.net:8080/preview.png", + ].map( + (url) => + [ + "disallowed registered source", + thumbnailRequest(url), + [{ ...plugin, screenshots: [url] }], + 400, + ] satisfies [string, Request, Plugin[], number], + ), + ] satisfies [string, Request, Plugin[], number][])( + "rejects %s without fetching an image", + async (_, request, plugins, status) => { + const upstream = imageFetch(); + const response = await handlePluginThumbnailRequest( + request, + [...plugins], + upstream.fetchImage, + ); + expect(response.status).toBe(status); + expect(upstream.calls).toEqual([]); + expect(response.headers.get("cache-control")).toBeNull(); + }, + ); + + it.each([ + () => new Response("missing", { status: 404 }), + () => new Response("error", { headers: { "content-type": "text/html" } }), + () => new Error("Origin unavailable"), + ])("does not cache a failed original for a year", async (failure) => { + const upstream = imageFetch(new Response("transform error", { status: 502 }), failure()); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("round-trips screenshot URL punctuation through the card URL", async () => { + const screenshot = source + "?name=a&other=b#preview"; + const upstream = imageFetch(imageResponse("thumbnail")); + const request = new Request( + "https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src, + ); + const response = await handlePluginThumbnailRequest( + request, + [{ ...plugin, screenshots: [screenshot] }], + upstream.fetchImage, + ); + expect(response.status).toBe(200); + expect(upstream.calls[0].input).toBe(screenshot); + }); +}); diff --git a/packages/website/src/plugins/thumbnails.ts b/packages/website/src/plugins/thumbnails.ts new file mode 100644 index 0000000000..755854d257 --- /dev/null +++ b/packages/website/src/plugins/thumbnails.ts @@ -0,0 +1,118 @@ +import type { Plugin } from "./registry"; + +// The single-column grid reaches 591px just below sm (640px minus 48px padding). +const CARD_WIDTH = 592; +const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]); +const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]); + +export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } { + if (!parseSource(source)) return { src: source }; + return { + src: pluginThumbnailUrl(id, source, 1), + srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`, + }; +} + +function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string { + return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; +} + +/** Only registry-listed screenshots are eligible for transformation or original fallback. */ +export async function handlePluginThumbnailRequest( + request: Request, + plugins: Plugin[], + fetchImage: typeof fetch = fetch, +): Promise { + const url = new URL(request.url); + const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname); + if (!match || !WIDTHS.has(Number(match[1]))) + return new Response("Invalid thumbnail width or source", { status: 400 }); + + let decoded: string; + try { + decoded = decodeURIComponent(match[2]); + } catch { + return new Response("Invalid thumbnail source", { status: 400 }); + } + const source = parseSource(decoded); + if (!source) return new Response("Invalid thumbnail source", { status: 400 }); + + const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin")); + if (!plugin?.screenshots.includes(source)) + return new Response("Screenshot not found", { status: 404 }); + + const format = negotiatedFormat(request.headers.get("accept") ?? ""); + let response: Response | undefined; + try { + response = await fetchImage(source, { + cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } }, + }); + } catch { + // An unavailable transformation must not prevent fetching the original. + } + if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) { + await response?.body?.cancel(); + try { + response = await fetchImage(source); + } catch { + return unavailableImage(); + } + } + if (!isImage(response)) { + await response.body?.cancel(); + return unavailableImage(); + } + return new Response(response.body, { + headers: { + "content-type": response.headers.get("content-type")!, + "cache-control": "public, max-age=31536000, immutable", + vary: "Accept", + // Originals can be SVGs. Keep direct navigation from executing them on our origin. + "content-security-policy": "sandbox", + "x-content-type-options": "nosniff", + }, + }); +} + +function isImage(response: Response): boolean { + return response.ok && /^image\//i.test(response.headers.get("content-type") ?? ""); +} + +function unavailableImage(): Response { + return new Response("Screenshot unavailable", { + status: 502, + headers: { "cache-control": "no-store" }, + }); +} + +function parseSource(source: string): string | null { + try { + const url = new URL(source); + if ( + url.protocol !== "https:" || + !SOURCE_HOSTS.has(url.hostname) || + url.username || + url.password || + url.port + ) + return null; + return source; + } catch { + return null; + } +} + +function negotiatedFormat(accept: string): "avif" | "webp" | undefined { + // The Worker API requires explicit negotiation; only the URL API accepts format=auto. + const accepted = new Map( + accept + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.split(";").map((part) => part.trim()); + const quality = parameters.find((parameter) => parameter.startsWith("q=")); + return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const; + }), + ); + return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0); +} From a292b402cf352c6a84e187928e4f9bd458485747 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 17:31:57 +0100 Subject: [PATCH 098/281] Resolve local plugin directories in the CLI's working directory (#6239) * fix(cli): resolve local plugin directories from the caller's cwd Relative plugin sources were sent unchanged, so a daemon started elsewhere inspected the wrong directory. Resolve directory sources and expand the caller's home before sending, preserving subdirectory suffixes and managed source identifiers. * fix(cli): preserve absolute plugin paths across host platforms A remote daemon may use another operating system. Recognize absolute POSIX and Windows directory sources before resolving relative paths on the CLI host. --- docs/plugins.md | 5 +- packages/cli/src/commands/plugin/index.ts | 16 +++- .../cli/tests/e2e/plugin-lifecycle.test.ts | 79 ++++++++++++++++++- public-docs/plugins/reference.md | 7 +- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index e9d5a18a9e..897bf29507 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -152,8 +152,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r ## Managed source ownership The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier -syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged -through `installPluginSource`; only the daemon resolves host paths and acquires sources. +syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before +calling `installPluginSource`; see the public source reference for CLI and app path semantics. +Only the daemon reads plugin files and acquires sources. `ManagedPluginSources` owns acquisition and offline source description. Config stores the active directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 3a126752c9..86dc5889b8 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,9 +1,12 @@ +import path from "node:path"; +import { expandUserPath } from "../../classify.js"; import { createInterface } from "node:readline/promises"; import { reviewPluginUpdates, type UpdateOutcome } from "./update.js"; import { Command } from "commander"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, + parsePluginSourceReference, formatPluginIdentity, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; @@ -125,7 +128,18 @@ export async function runPluginInstallCommand( process.stderr.write( "Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); - const sourceReference = formatPluginSourceReference(source, options.path); + const reference = parsePluginSourceReference(source); + let resolvedSource = source; + if (reference.kind === "directory") { + const directory = expandUserPath(reference.source); + // An absolute path can target a daemon on a different operating system. + const absoluteDirectory = + path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory) + ? directory + : path.resolve(directory); + resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath); + } + const sourceReference = formatPluginSourceReference(resolvedSource, options.path); const data = await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index ce509af8e1..3e71b6b608 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -8,11 +8,13 @@ import { } from "../../../../scripts/test-support/npm-registry.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; import { connectToDaemon } from "../../src/utils/client.ts"; import { createE2ETestContext } from "../helpers/test-daemon.ts"; @@ -45,6 +47,81 @@ async function main(): Promise { ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); + // Observe real CLI requests while forwarding them to the isolated daemon. + const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sources: string[] = []; + proxy.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const message = JSON.parse(data.toString()); + if ( + message.type === "session" && + message.message.type === "plugin.source.install.request" + ) { + sources.push(message.message.source); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(proxy, "listening"); + const address = proxy.address(); + assert.ok(address && typeof address !== "string"); + const sibling = path.join(context.workDir, "x"); + const caller = path.join(context.workDir, "caller"); + await mkdir(caller); + await mkdir(path.join(sibling, "sub"), { recursive: true }); + for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) { + await writeFile( + path.join(target, "paseo-plugin.json"), + JSON.stringify({ + id: "relative-cli", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(target, "index.server.ts"), pluginSource); + } + assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd. + try { + for (const [source, expectedSource, expectedDirectory, extraArgs] of [ + [".", caller, caller, []], + ["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []], + ["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]], + ["~", context.paseoHome, context.paseoHome, []], + ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], + ] as const) { + sources.length = 0; + const result = await context.paseo( + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [expectedSource]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).path, expectedDirectory); + const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + // An absolute path belongs to the daemon's platform, which can differ from the CLI's. + const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; + sources.length = 0; + const missing = await context.paseo( + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [windowsSource]); + assert.equal(missing.exitCode, 1); + assert.match(missing.stderr, /Plugin directory does not exist/); + } finally { + for (const socket of proxy.clients) socket.close(); + await promisify(proxy.close.bind(proxy))(); + } + const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index d4d67fae9b..2a8714e4d7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2258,8 +2258,11 @@ failures stay inside the plugin error boundary. Paste one of these source identifiers into **Settings → Plugins**, or pass it to `paseo plugin install`. `paseo plugin add ` and `paseo plugin install ` are aliases. -Absolute host paths are recommended because relative paths resolve against the daemon's working -directory. The daemon expands `~` to its home directory. +The CLI resolves relative directory paths from your current working directory and expands `~` +to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute +path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI. +Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to +the daemon's home directory. | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | From a9fef4879afd810f4d3ff0b99d8422cc95e90a44 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:54:59 +0200 Subject: [PATCH 099/281] Show an archived ACP agent's history after its worktree is removed (#6228) * Show an archived ACP agent's history after its worktree is removed Opening an archived ACP agent loads its history by resuming the provider session for reading. The provider process was spawned in the agent's stored working directory, so once that directory was removed (for example when its worktree workspace was archived) the spawn failed with ENOENT and the chat opened empty. A history load now starts the provider process from the home directory when the working directory is gone, and still names the original directory in session/load. An interactive resume keeps requiring the directory. Closes #5449 * Only treat a missing working directory as gone for a history load A stat failure other than a missing path, such as a permission error, now reaches the caller instead of moving the provider to the home directory. * Start the history process from home when the stored path is not a directory A file left where an archived agent's worktree was made the history load spawn in it and fail with ENOTDIR. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/providers/acp-agent.ts | 31 +++- ...generic-acp-agent.archived-history.test.ts | 154 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index a647deb549..e5d1e4c48e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -76,6 +76,8 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -148,6 +150,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis }); } +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -497,6 +511,7 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; agentId?: string; launchEnv?: Record; waitForInitialCommands?: boolean; @@ -1006,6 +1021,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -1047,6 +1063,7 @@ export class ACPAgentClient implements AgentClient { providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, handle, + resumePurpose: options?.purpose, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1700,6 +1717,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; @@ -1753,6 +1771,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -2770,6 +2789,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { return {}; } + // Loading an archived agent's history runs nothing in its working directory, which + // may have been removed with its worktree. session/load still names the original + // directory; only the process starts from the home directory. + private async resolveProcessCwd(): Promise { + if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { + return this.config.cwd; + } + return homedir(); + } + private async spawnProcess(): Promise { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -2783,7 +2812,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const child = spawnProcess(command, args, { - cwd: this.config.cwd, + cwd: await this.resolveProcessCwd(), ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings, overlays: [this.launchEnv], diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts new file mode 100644 index 0000000000..ec17dd7ecf --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient archived history", () => { + test("replays an archived session whose working directory was removed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8")); + expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]); + } finally { + await session.close(); + } + }); + }); + + test("replays an archived session whose working directory path is now a file", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + await writeFile(removedCwd, "not a directory", "utf8"); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("still requires the working directory to resume a session that will run", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + await expect( + client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ), + ).rejects.toThrow("ENOENT"); + }); + }); +}); + +async function collectTimelineText(events: AsyncGenerator): Promise { + const text: string[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message" || event.item.type === "assistant_message") { + text.push(`${event.item.type}:${event.item.text}`); + } + } + return text; +} + +async function withFakeACPAgent( + run: (input: { + command: [string, ...string[]]; + removedCwd: string; + loadRequestsPath: string; + }) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + const loadRequestsPath = path.join(testDir, "load-requests.json"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run({ + command: [process.execPath, scriptPath, loadRequestsPath], + removedCwd: path.join(testDir, "removed-worktree"), + loadRequestsPath, + }); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const fs = require("node:fs"); +const readline = require("node:readline"); + +const loadRequestsPath = process.argv[2]; +const loadRequests = []; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +function replay(sessionId, sessionUpdate, text) { + write({ + method: "session/update", + params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } }, + }); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/load") { + const { sessionId, cwd } = message.params; + loadRequests.push({ sessionId, cwd }); + fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests)); + replay(sessionId, "user_message_chunk", "What is in the README?"); + replay(sessionId, "agent_message_chunk", "It describes the project."); + write({ id: message.id, result: {} }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; From fc3b2c4eb7a6e294b6e2761400fe48b9e0b0aee9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 18:31:53 +0100 Subject: [PATCH 100/281] Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244) * fix(plugins): restrict overview content to safe Markdown and HTTPS destinations Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content. * fix(plugins): choose the first allowed screenshot for previews --- docs/plugins.md | 7 + package-lock.json | 1 + .../app/e2e/browser/plugin-management.spec.ts | 25 + packages/protocol/src/plugin-overview.test.ts | 42 + packages/protocol/src/plugin-overview.ts | 49 + .../tests/fixtures/plugin-overview.json | 1202 +++++++++++++++++ packages/website/e2e/overview.fixture.json | 32 + packages/website/e2e/plugins.spec.ts | 74 +- packages/website/e2e/registry-server.mjs | 13 +- packages/website/package.json | 1 + .../website/src/plugins/overview.test.tsx | 138 ++ packages/website/src/plugins/overview.tsx | 56 + packages/website/src/plugins/plugin-card.tsx | 5 +- packages/website/src/plugins/plugin-tile.tsx | 3 +- .../website/src/routes/plugins/$owner.tsx | 14 +- .../src/routes/plugins/$owner_.$slug.tsx | 54 +- 16 files changed, 1673 insertions(+), 43 deletions(-) create mode 100644 packages/protocol/src/plugin-overview.test.ts create mode 100644 packages/protocol/src/plugin-overview.ts create mode 100644 packages/protocol/tests/fixtures/plugin-overview.json create mode 100644 packages/website/e2e/overview.fixture.json create mode 100644 packages/website/src/plugins/overview.test.tsx create mode 100644 packages/website/src/plugins/overview.tsx diff --git a/docs/plugins.md b/docs/plugins.md index 897bf29507..866c74128e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -123,6 +123,13 @@ Use `git:owner/repository` or a full Git URL for a Git source. Registry installs keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref selection is unavailable for registry installs; install an explicit source to select your own. +Registry overviews are untrusted author content, even before installation. Render them with +`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative +URLs, and isolated external links. The website adapter and corpus tests live in +`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is +`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share +this policy. Installed-plugin descriptions in the app remain plain text. + Use `host/owner/slug` for a registry at `https://host`, or set `PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). Private registry credentials live in daemon config under diff --git a/package-lock.json b/package-lock.json index d03e712ee1..a2856c464a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40174,6 +40174,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 05a2dd765e..84b4cc63a2 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) { await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible(); await expect(page.getByLabel("npm-review running")).toHaveCount(0); } + +test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => { + const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n"); + await installPluginWithUntrustedDescription(pluginEnvironment, description); + await gotoAppShell(page); + await openPluginSettings(page); + const text = page.getByText(description, { exact: true }); + await expect(text).toBeVisible(); + // This field must remain a text node, with no Markdown links, images, or HTML children. + expect(await text.evaluate((element) => element.childElementCount)).toBe(0); +}); + +async function installPluginWithUntrustedDescription( + environment: PluginEnvironment, + description: string, +) { + const directory = await createDirectoryPlugin( + environment.directory, + "plain-text-plugin", + description, + "Plain text plugin", + ); + await environment.client.installPluginSource({ source: directory }); +} diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pluginOverviewUrl } from "./plugin-overview.js"; + +describe("plugin overview destinations", () => { + it.each([ + undefined, + "", + "./docs", + "../docs", + "/login", + "//example.com", + "#section", + "?login", + "http://example.com", + "mailto:hello@example.com", + "ftp://example.com", + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "vbscript:msgbox(1)", + "javascript:alert(1)", + "javascript%3Aalert(1)", + "javascript%253Aalert(1)", + "java\tscript:alert(1)", + " https://example.com", + "https://example.com\n", + "https:example.com", + "https:///example.com", + "https://paseo.sh@evil.example", + "https://example.com\\@evil.example", + "https://", + ])("leaves %s as text", (url) => { + expect(pluginOverviewUrl(url)).toBeUndefined(); + }); + + it.each([ + "https://example.com/path?q=a&b=c#section", + "HTTPS://example.com/path", + "https://example.com/%3Cscript%3E", + ])("allows %s without decoding it again", (url) => { + expect(pluginOverviewUrl(url)).toBe(url); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -0,0 +1,49 @@ +/** Presentation contract for author-controlled registry content, independent of the UI runtime. */ +export const pluginOverviewPolicy = { + rawHtml: false, + elements: [ + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + link: { rel: "noopener noreferrer nofollow", target: "_blank" }, +} as const; + +/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */ +export function pluginOverviewUrl(value: string | undefined): string | undefined { + // URL parsers discard controls and normalize backslashes; reject those ambiguous spellings. + // eslint-disable-next-line no-control-regex + if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password) + return undefined; + return value; + } catch { + return undefined; + } +} diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "
\n\n
", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "
", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "

click

", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "", + "links": [], + "images": [] + } +] diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2d23fd0343 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "", + "description": "", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "" + }, + "screenshots": [ + "data:image/svg+xml,", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 910f14cc1b..246e383b57 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,3 +1,4 @@ +import securityPlugin from "./overview.fixture.json" with { type: "json" }; import { expect, test, type Page, type Locator } from "playwright/test"; import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; @@ -35,7 +36,7 @@ test("browses from the directory into a category, a plugin, and its author", asy await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect @@ -168,7 +169,7 @@ test.describe("search engine visits without JavaScript", () => { expect(response?.headers()["x-robots-tag"]).toBeUndefined(); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await expectPageMetadata( page, @@ -366,3 +367,72 @@ async function expectThumbnailCard(card: Locator, source: string, id: string) { expect(box.width).toBeGreaterThan(0); expect(box.width / box.height).toBeCloseTo(1.6, 2); } + +test("keeps author content inert through SSR and hydration", async ({ + page, + request, +}, testInfo) => { + await verifyPluginOverviewResponse(request); + await openUntrustedPlugin(page); + await expectPluginOverviewSafe(page); + await page.reload(); + await expectPluginOverviewSafe(page); + await testInfo.attach("plugin-overview", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + +async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) { + const response = await request.get("/plugins/security/overview"); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).not.toContain(""); + expect(html).toContain("\\x3C/script>"); +} + +async function openUntrustedPlugin(page: Page) { + await page.goto("/plugins/security/overview"); + await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible(); +} + +async function expectPluginOverviewSafe(page: Page) { + expect( + await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted), + ).toBeUndefined(); + // Security assertions inspect every emitted attribute, including inaccessible injected elements. + const violations = await page.locator(".docs-prose").evaluate((root) => { + const bad: string[] = []; + for (const element of root.querySelectorAll("*")) { + if ( + ["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes( + element.tagName, + ) + ) + bad.push(element.tagName); + for (const attr of element.attributes) { + if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name); + if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://")) + bad.push(attr.value); + } + if ( + element.tagName === "A" && + (element.getAttribute("rel") !== "noopener noreferrer nofollow" || + element.getAttribute("target") !== "_blank") + ) + bad.push("unsafe anchor"); + } + return bad; + }); + expect(violations).toEqual([]); + await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0); + await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name); + await expect( + page.getByRole("link", { name: securityPlugin.author.name, exact: true }), + ).toHaveAttribute("href", "/plugins/security"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + securityPlugin.description, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs index 211fee9f38..228f3dd859 100644 --- a/packages/website/e2e/registry-server.mjs +++ b/packages/website/e2e/registry-server.mjs @@ -1,6 +1,15 @@ import { createServer } from "node:http"; import registry from "./registry.fixture.json" with { type: "json" }; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; + +import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" }; +securityPlugin.readme += + "\n\n" + + overviewCorpus + .map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```") + .join("\n\n"); + // Serve the published registry contract so SSR and browser navigation use the same data. createServer((request, response) => { response.setHeader("Content-Type", "application/json"); @@ -8,7 +17,9 @@ createServer((request, response) => { response.end(JSON.stringify(registry)); return; } - const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + const plugin = [securityPlugin, ...registry.plugins].find( + (entry) => request.url === `/plugins/${entry.id}.json`, + ); response.statusCode = plugin ? 200 : 404; response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); }).listen(8188, "127.0.0.1"); diff --git a/packages/website/package.json b/packages/website/package.json index 3176fd25d5..bf40c0e1a6 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -38,6 +38,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/website/src/plugins/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx new file mode 100644 index 0000000000..89cf15a947 --- /dev/null +++ b/packages/website/src/plugins/overview.test.tsx @@ -0,0 +1,138 @@ +import registry from "../../e2e/registry.fixture.json"; +import { PluginTile } from "./plugin-tile"; +import { PluginCard } from "./plugin-card"; +import type { Plugin } from "./registry"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; +import { describe, expect, it } from "vitest"; +import corpus from "../../../protocol/tests/fixtures/plugin-overview.json"; +import { PluginOverview } from "./overview"; + +type Element = DefaultTreeAdapterMap["element"]; +function elements(html: string): Element[] { + const result: Element[] = []; + function visit(node: DefaultTreeAdapterMap["node"]) { + if ("tagName" in node) result.push(node); + if ("childNodes" in node) node.childNodes.forEach(visit); + } + visit(parseFragment(html)); + return result; +} +function render(markdown: string) { + return renderToStaticMarkup({markdown}); +} +function attribute(element: Element, name: string) { + return element.attrs.find((attr) => attr.name === name)?.value; +} +const allowedTags = new Set([ + "div", + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", +]); + +describe("plugin overview security corpus", () => { + it.each(corpus)("$name", ({ markdown, links, images }) => { + const nodes = elements(render(markdown)); + expect( + nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName), + ).toEqual([]); + expect( + nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)), + ).toEqual([]); + const anchors = nodes.filter((node) => node.tagName === "a"); + expect(anchors.map((node) => attribute(node, "href"))).toEqual(links); + for (const anchor of anchors) { + expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow"); + expect(attribute(anchor, "target")).toBe("_blank"); + } + expect( + nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), + ).toEqual(images); + }); + + it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => { + for (const code of [ + `\`${markdown.replaceAll("\n", " ")}\``, + `\`\`\`html\n${markdown}\n\`\`\``, + ]) { + const nodes = elements(render(code)); + expect( + nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)), + ).toEqual([]); + const text = nodes + .filter((node) => node.tagName === "code") + .flatMap((node) => node.childNodes) + .map((child) => ("value" in child ? child.value : "")) + .join(""); + expect(text.trim()).toBe( + code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "), + ); + } + }); + + it("keeps long untrusted input inert", () => { + const nodes = elements( + render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)), + ); + expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]); + }); +}); + +describe("registry images", () => { + it.each([ + "http://example.com/x", + "data:image/png;base64,AAAA", + "javascript:alert(1)", + "vbscript:msgbox(1)", + "./icon.png", + "//example.com/x", + ])("does not load %s in cards or icons", (url) => { + const plugin = { ...registry.plugins[0], icon: url, screenshots: [url] } as Plugin; + for (const component of [ + createElement(PluginTile, { plugin, size: "sm" }), + createElement(PluginCard, { plugin }), + ]) { + expect( + elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"), + ).toEqual([]); + } + }); +}); + +it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => { + const source = "https://example.com/screenshot.png"; + const plugin = { + ...registry.plugins[0], + icon: undefined, + screenshots: ["data:image/png;base64,AAAA", source], + } as Plugin; + const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin }))); + expect( + nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), + ).toEqual([source]); +}); diff --git a/packages/website/src/plugins/overview.tsx b/packages/website/src/plugins/overview.tsx new file mode 100644 index 0000000000..8fc680f9a9 --- /dev/null +++ b/packages/website/src/plugins/overview.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; + +/** A rejected destination leaves its label as text, without even an empty anchor. */ +export function PluginContentLink({ + href, + children, + className, +}: { + href?: string; + children: ReactNode; + className?: string; +}) { + const destination = pluginOverviewUrl(href); + return destination ? ( + + {children} + + ) : ( + children + ); +} + +const allowedElements = [...pluginOverviewPolicy.elements]; +const remarkPlugins = [remarkGfm]; + +const components: Components = { + a: ({ href, children }) => {children}, + img: ({ src, alt }) => { + const source = pluginOverviewUrl(typeof src === "string" ? src : undefined); + return source ? {alt : alt; + }, + // GFM alignment otherwise becomes an inline style in react-markdown. + th: ({ children }) => {children}, + td: ({ children }) => {children}, +}; + +/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */ +export function PluginOverview({ children }: { children: string }) { + return ( +
+ + {children} + +
+ ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index 4df1d70d1d..429937ab87 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,3 +1,4 @@ +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { Download } from "lucide-react"; import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; @@ -7,9 +8,9 @@ import { formatInstalls, getCategory, type Plugin } from "./registry"; export const PLUGIN_GRID_CLASS = "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; -/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */ +/** First allowed screenshot, or the plugin tile on a quiet backdrop when there is none. */ function PluginShot({ plugin }: { plugin: Plugin }) { - const url = plugin.screenshots[0]; + const url = plugin.screenshots.find(pluginOverviewUrl); return (
- {plugin.icon ? ( + {pluginOverviewUrl(plugin.icon) ? ( ) : ( plugin.name.charAt(0).toUpperCase() diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index e4db39a205..0345fa1355 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -1,3 +1,4 @@ +import { PluginContentLink } from "~/plugins/overview"; import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; import { useMemo } from "react"; @@ -64,21 +65,16 @@ function AuthorPage() {

{authorNpmUrl(author) && ( - + npm - + )} {github && ( - + GitHub - + )}
diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index b70a13d9da..42529775f8 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,7 +1,8 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; import { CodeBlock } from "~/components/code-block"; -import { DocsMarkdown } from "~/components/docs-markdown"; +import { PluginOverview, PluginContentLink } from "~/plugins/overview"; +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; import { @@ -34,7 +35,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", loaderData?.plugin?.description ?? "Plugin not found.", `/plugins/${params.owner}/${params.slug}`, - loaderData?.plugin.screenshots[0], + loaderData?.plugin.screenshots.find(pluginOverviewUrl), ), component: PluginPage, notFoundComponent: () => ( @@ -75,46 +76,43 @@ function PluginPage() { )} {formatInstalls(installs)} installs {pluginVersion(plugin)} - + Source - + {npm && ( - + npm - + )}
{plugin.screenshots.length > 0 && (
- {plugin.screenshots.map((url, index) => ( - - {`${plugin.name} - - ))} + {plugin.screenshots.map((url, index) => + pluginOverviewUrl(url) ? ( + + {`${plugin.name} + + ) : ( +

{url}

+ ), + )}
)}
- {readmeBody(plugin.readme)} + {readmeBody(plugin.readme)}
From 13d21f60f1b7888a6105d8c20d044483e6475452 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:40:48 +0000 Subject: [PATCH 101/281] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d41138cbb1..32aedbf346 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE= +sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4= From 05637a7f052630a35f07e1264c95646d414ef33a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:54:16 +0200 Subject: [PATCH 102/281] Show a Claude subagent's auto-mode report as markdown instead of a raw SubagentHandback card (#6248) * Show a Claude subagent's handback report as its final message Since Claude Code 2.1.271, a subagent in auto mode delivers its report through a SubagentHandback tool call instead of its final text. Paseo showed that call as a generic tool card with the report as escaped JSON. Live sidechain frames and replayed history now map the call to the subagent's final assistant message and hide its acknowledgement. Closes #6241 * Keep a handback report's leading whitespace and hide its acknowledgement by call id --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.sub-agent-replay.test.ts | 52 ++++++++++++++++ .../agent.subagent-handback.real.e2e.test.ts | 56 +++++++++++++++++ .../server/agent/providers/claude/agent.ts | 22 ++++++- .../claude/sidechain-tracker.test.ts | 60 +++++++++++++++++++ .../providers/claude/sidechain-tracker.ts | 29 ++++++++- .../providers/claude/subagent-handback.ts | 27 +++++++++ 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagent-handback.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 8701ab87ec..48ddab5973 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(timeline.length).toBeGreaterThan(0); expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); }); + + test("replays a SubagentHandback report as the subagent's final message", async () => { + const handbackId = "toolu_handback"; + const report = "## Verdict\n\n- **Coherent**"; + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: handbackId, + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: handbackId, + content: [ + { + type: "text", + text: '{"success":true,"message":"Report delivered to your caller."}', + }, + ], + }, + ], + }, + }), + ], + }); + + const items = (await replayDescriptors()).flatMap((event) => + event.event.type === "timeline" ? [event.event.item] : [], + ); + expect(items).toEqual([{ type: "assistant_message", text: report }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts new file mode 100644 index 0000000000..e5e8d117a4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task: + +Do not use any tools. Reply with exactly this markdown report and nothing else: +## HANDBACK_HEADING +- **HANDBACK_BOLD** item + +Then reply exactly ROOT_DONE.`; + +function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] { + return events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [], + ); +} + +function expectHandbackAsReport(items: AgentTimelineItem[]): void { + expect(items.filter((item) => item.type === "tool_call")).toEqual([]); + expect( + items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""), + ).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item"); +} + +test("an auto-mode Claude subagent's handback report shows as its final message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) }); + const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + expectHandbackAsReport(subagentItems(events)); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expectHandbackAsReport(subagentItems(replayed)); + } finally { + await restored.close(); + } + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index aa60e40462..f9f79862ad 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, @@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map(); private toolUseIndexToId = new Map(); private toolUseInputBuffers = new Map(); + private subagentHandbackIds = new Set(); private pendingPermissions = new Map(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; @@ -5184,7 +5186,7 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - if (!isClaudeContentChunk(block)) { + if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } this.mapBlockToTimeline(block, { @@ -5272,6 +5274,24 @@ class ClaudeAgentSession implements AgentSession { } } + // A subagent's handback call is its final message, and the call's result is only an + // acknowledgement, so neither becomes a tool call. + private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean { + if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) { + this.subagentHandbackIds.delete(block.tool_use_id); + return true; + } + const handback = readClaudeSubagentHandback(block); + if (!handback) { + return false; + } + this.subagentHandbackIds.add(handback.callId); + if (handback.report) { + items.push({ type: "assistant_message", text: handback.report }); + } + return true; + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} From 3c478e59673e77a4d82ca18dfa3ea6e03119a83e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 20:53:34 +0100 Subject: [PATCH 103/281] website: plugins page spacing, featured section, media with video, and viewer (#6254) * website: match left and right edge padding on ranked plugin rows The rank sat right-aligned in a fixed 16px column and the row had 8px of padding, so a single digit started ~15.6px from the row edge while the install count ended 8px from it. The rank now sits at the row's padding, which is 16px on both sides, and the list's negative margin matches so the rank still lines up with the section heading. Tabular figures keep the tile column aligned without the fixed width. * website: give plugins section headings room above their content Section headings sat 16px above their cards while sections sat 56px apart, so a heading read as closer to the section above it. A shared PluginSection now owns the header-to-content gap (32px), used by the index sections and by the browse results under the sort tabs. * website: center the Build your own plugin card The heading, description, and buttons were left-aligned in a full-width card, leaving most of it empty. Center them and keep the description at its readable measure. * website: order What's new by first-listing date newestFirst and addedAgo read submittedAt. The registry publishes the first-listing date as publishedAt, so What's new now sorts and dates by it. The fixture's publishedAt values become first-listing dates. * website: plugin media with video tiles and a click-to-view viewer The registry's published screenshots field becomes media and may hold mp4 and webm videos. Cards and link previews use the first image, the thumbnail route serves only image entries, and the detail gallery shows videos as tiles alongside images. Any tile opens one viewer. An image fits the viewport but never exceeds its natural size. A video plays with controls and scales to the largest size that fits, small sources included. The daemon resolver now validates only the ID and artifact it installs. * server: drop the unreleased pluginRegistryEnabled config key The key only existed in unreleased betas and nothing reads it. The config schema is strict, so a config that still sets it now fails validation and names the key. * website: Featured section on the plugins index The registry index gains a required featured list of plugin IDs. The index shows those plugins above What's new, in the registry's order, skipping IDs that are not listed, and hides the section when none are. The index sections now share one container for their spacing. --- packages/protocol/src/plugin-registry.test.ts | 18 ++ packages/protocol/src/plugin-registry.ts | 12 +- packages/server/src/server/config.test.ts | 11 +- .../server/src/server/persisted-config.ts | 2 - .../src/server/plugins/index.posix.test.ts | 2 +- .../plugins/managed-source.posix.test.ts | 14 +- .../server/plugins/managed-source/registry.ts | 12 +- packages/website/e2e/overview.fixture.json | 2 +- packages/website/e2e/plugins.spec.ts | 98 ++++++++++- packages/website/e2e/registry.fixture.json | 81 +++++---- packages/website/src/plugins/browse-page.tsx | 67 ++++---- .../website/src/plugins/contribute-links.tsx | 6 +- packages/website/src/plugins/loader.ts | 9 +- .../website/src/plugins/media-gallery.tsx | 158 ++++++++++++++++++ .../website/src/plugins/overview.test.tsx | 4 +- packages/website/src/plugins/plugin-card.tsx | 13 +- packages/website/src/plugins/registry.test.ts | 48 +++++- packages/website/src/plugins/registry.ts | 20 ++- packages/website/src/plugins/section.tsx | 35 ++++ .../website/src/plugins/thumbnails.test.ts | 29 +++- packages/website/src/plugins/thumbnails.ts | 5 +- .../src/routes/plugins/$owner_.$slug.tsx | 28 +--- packages/website/src/routes/plugins/index.tsx | 158 ++++++++++-------- 23 files changed, 610 insertions(+), 222 deletions(-) create mode 100644 packages/protocol/src/plugin-registry.test.ts create mode 100644 packages/website/src/plugins/media-gallery.tsx create mode 100644 packages/website/src/plugins/section.tsx diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts index 36c5354c88..90854962ba 100644 --- a/packages/protocol/src/plugin-registry.ts +++ b/packages/protocol/src/plugin-registry.ts @@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({ repository: z.object({ url: z.string().url(), commit: z.string().optional() }), artifact: PluginRegistryArtifactSchema, icon: z.string().url().optional(), - screenshots: z.array(z.string().url()), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), submittedAt: z.string(), reviewedAt: z.string(), updatedAt: z.string(), publishedAt: z.string(), installs: z.number().int().nonnegative().optional(), }); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); export const PluginRegistryIndexSchema = z.object({ schemaVersion: z.literal(1), @@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({ z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), ), plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), generatedAt: z.string(), }); export type PluginRegistryIdentity = z.infer; diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index fa49722304..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -205,19 +205,12 @@ test("loads private plugin registry settings through the configuration boundary" const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); try { const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; - await writeFile( - path.join(home, "config.json"), - JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }), - ); + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); const config = loadConfig(home, { - env: { - PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal", - PASEO_PLUGIN_REGISTRY_ENABLED: "false", - }, + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); - expect(config).not.toHaveProperty("pluginRegistryEnabled"); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 24ded0dec2..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -308,8 +308,6 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), - // COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06. - pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 464ad446f0..f9592597f1 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -494,7 +494,7 @@ describe("PluginService", () => { commit: stdout.trim(), pluginPath: ".", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index ab9d6c50fb..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -433,7 +433,7 @@ describe("registry plugin sources", () => { commit, pluginPath: "packages/example", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -516,20 +516,10 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", const server = createServer((request, response) => { intents.push(request.headers["x-paseo-install"]); response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. JSON.stringify({ id: "acme/example", - name: "Example", - description: "Test", - categories: [], - author: { github: "acme" }, - repository: { url: "https://github.com/acme/example" }, artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, - screenshots: [], - submittedAt: "2026-10-03", - reviewedAt: "2026-10-03", - updatedAt: "2026-10-03", - publishedAt: "2026-10-03", - readme: "# Example", }), ); }); diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index b57aac6c0c..009cac5a68 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -1,9 +1,17 @@ import { - PublishedPluginDetailSchema, + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, type PluginRegistries, type PluginRegistryIdentity, } from "@getpaseo/protocol/plugin-registry"; import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); export interface RegistryOptions { defaultUrl?: string; @@ -49,7 +57,7 @@ export async function resolveRegistryPlugin( } if (!response.ok) throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); - const plugin = PublishedPluginDetailSchema.parse(await response.json()); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); const artifact = plugin.artifact; const target: PluginUpdateTarget = diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json index 2d23fd0343..2553fe83c6 100644 --- a/packages/website/e2e/overview.fixture.json +++ b/packages/website/e2e/overview.fixture.json @@ -18,7 +18,7 @@ "github": "security", "name": "" }, - "screenshots": [ + "media": [ "data:image/svg+xml,", "http://example.com/image.png" ], diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 246e383b57..3398a7e7d6 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -312,7 +312,7 @@ test.describe("registry fixture layout", () => { page, }) => { const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; - const source = plugin.screenshots[0]; + const source = plugin.media[0]; await openPlugins(page); await expectThumbnailCard( page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), @@ -328,6 +328,85 @@ test.describe("registry fixture layout", () => { ).toHaveAttribute("src", source); }); + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + await expect(card.locator("img, video")).toHaveCount(0); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("Hand-picked by the maintainer."); + await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ + /Dracula/, + /Herald/, + /launchd Jobs/, + ]); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -343,6 +422,23 @@ test.describe("registry fixture layout", () => { }); }); +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + async function expectThumbnailCard(card: Locator, source: string, id: string) { // Card screenshots are decorative and hidden from the accessibility tree. const image = card.locator("img").first(); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 6b75599d3d..2f6560780c 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -5,6 +5,12 @@ "url": "https://plugins.paseo.sh" }, "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], "categories": [ { "slug": "daemon-management", @@ -74,12 +80,12 @@ "name": "alhassanaraouf", "github": "alhassanaraouf" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-24T17:12:05.467Z", + "publishedAt": "2026-09-24T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." @@ -106,13 +112,13 @@ "name": "Tom Lundy", "github": "dorasto" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T10:39:28.935Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" @@ -139,13 +145,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T17:46:20.500Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" @@ -172,13 +178,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T19:03:31.943Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" @@ -205,12 +211,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ - "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T17:56:41.061Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" @@ -237,12 +243,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T18:03:14.979Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" @@ -269,10 +275,10 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T22:25:58.192Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" @@ -298,13 +304,13 @@ "name": "hungcuong9125", "github": "hungcuong9125" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-30T02:57:56.473Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." @@ -331,13 +337,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:35.639Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." @@ -364,13 +370,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:29.149Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." @@ -397,13 +403,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:18.185Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." @@ -430,13 +437,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:06.720Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" @@ -463,13 +470,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:36.267Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." @@ -496,10 +503,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:04.327Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." @@ -526,13 +533,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:38:48.289Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." @@ -559,10 +566,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:15.719Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." @@ -588,12 +595,12 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T09:59:16.553Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." @@ -619,10 +626,10 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-23T10:36:50.112Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 0cfbd805db..a6d77347fe 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -5,6 +5,7 @@ import type { InstallCounts } from "./installs"; import { type BrowseQuery, browseHref } from "./links"; import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; import { PluginSearch } from "./plugin-search"; +import { PluginSection } from "./section"; import { addedAgo, CATEGORIES, @@ -72,36 +73,38 @@ export function BrowsePage({ )}
- - {results.length === 0 && query.q && ( -
-

No plugins match.

- - Clear filters - -
- )} - {results.length === 0 && !query.q && ( -

No plugins in this category yet.

- )} - {results.length > 0 && ( -
- {results.map((plugin) => - query.sort === "new" ? ( - - ) : ( - - ), - )} -
- )} + + + {results.length === 0 && query.q && ( +
+

No plugins match.

+ + Clear filters + +
+ )} + {results.length === 0 && !query.q && ( +

No plugins in this category yet.

+ )} + {results.length > 0 && ( +
+ {results.map((plugin) => + query.sort === "new" ? ( + + ) : ( + + ), + )} +
+ )} +
@@ -186,7 +189,7 @@ function SortTabs({ query }: { query: BrowseQuery }) { ); return ( - <> + ); } diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index 476146dfca..3aeb33a16b 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -26,16 +26,16 @@ export function ContributeSection() { return (

Build your own plugin

-

+

Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one with the plugin docs, then submit it to get it listed here.

-
+
Read the docs diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts index 27416a4125..ebc9bf8272 100644 --- a/packages/website/src/plugins/loader.ts +++ b/packages/website/src/plugins/loader.ts @@ -11,14 +11,19 @@ import { function registryBase(): string { return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; } -/** Listed plugins in index order, their install counts per window, and the server's clock. */ +/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */ export const getRegistry = createServerFn({ method: "GET" }).handler(async () => { const context = getWebsiteCacheContext(); const [index, installs] = await Promise.all([ loadRegistryIndex(registryBase(), context), loadInstallCounts(registryBase(), context), ]); - return { plugins: index.plugins, installs, now: new Date().toISOString() }; + return { + plugins: index.plugins, + featured: index.featured, + installs, + now: new Date().toISOString(), + }; }); export const getRegistryPlugin = createServerFn({ method: "GET" }) .validator((id: string) => id) diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx new file mode 100644 index 0000000000..5fe47c4631 --- /dev/null +++ b/packages/website/src/plugins/media-gallery.tsx @@ -0,0 +1,158 @@ +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import { Play, X } from "lucide-react"; +import { + type CSSProperties, + type MouseEvent, + type SyntheticEvent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +const TILE_CLASS = + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"; +// The viewer's room: media never exceeds this box, which leaves a margin around it. +const VIEW_WIDTH = "90vw"; +const VIEW_HEIGHT = "85vh"; +const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; + +interface MediaItem { + url: string; + kind: "image" | "video"; + /** Image alt text, or the video's accessible name. */ + label: string; +} + +/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ +export function MediaGallery({ name, media }: { name: string; media: string[] }) { + const dialog = useRef(null); + const [viewing, setViewing] = useState(null); + const open = useCallback((item: MediaItem) => { + setViewing(item); + dialog.current?.showModal(); + }, []); + const close = useCallback(() => dialog.current?.close(), []); + // Unmounting the viewed media stops a playing video. + const handleClose = useCallback(() => setViewing(null), []); + const closeOnBackdrop = useCallback((event: MouseEvent) => { + if (event.target === event.currentTarget) event.currentTarget.close(); + }, []); + + if (media.length === 0) return null; + return ( + <> +
+ {media.map((url, index) => { + const source = pluginOverviewUrl(url); + if (!source) return

{url}

; + const kind = pluginMediaKind(source); + const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`; + return ; + })} +
+ + {viewing?.kind === "image" && ( + {viewing.label} + )} + {viewing?.kind === "video" && } + + + + ); +} + +/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ +function ViewerVideo({ url, label }: { url: string; label: string }) { + const [aspectRatio, setAspectRatio] = useState(null); + const readAspectRatio = useCallback((event: SyntheticEvent) => { + const { videoWidth, videoHeight } = event.currentTarget; + setAspectRatio(videoWidth / videoHeight); + }, []); + const style = useMemo( + () => + aspectRatio === null + ? undefined + : { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` }, + [aspectRatio], + ); + return ( +
-
-
-

- What’s new -

- - See all - - -
-
- {newest.map((plugin) => ( - - ))} -
-
+
+ {featured.length > 0 && ( + +
+ Featured +

Hand-picked by the maintainer.

+
+
+ {featured.map((plugin) => ( + + ))} +
+
+ )} -
-

- Categories -

-
- {CATEGORIES.map((category) => { - const Icon = CATEGORY_ICONS[category.slug]; - return ( - - - - {category.label} - - - {getPluginsInCategory(plugins, category.slug).length} - - - ); - })} -
-
- -
- -
- {top.map((plugin, index) => ( - - ))} -
-
+ +
+ {newest.map((plugin) => ( + + ))} +
+ + + + Categories +
+ {CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category.slug]; + return ( + + + + {category.label} + + + {getPluginsInCategory(plugins, category.slug).length} + + + ); + })} +
+
+ + + + + + Most installed + + + + + +
+ {top.map((plugin, index) => ( + + ))} +
+
+
From 4515061f88d040f30f8641d693cc6cb70ce8e9fc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 20:53:42 +0100 Subject: [PATCH 104/281] website: submit-based plugin search on the home page, back button works on browse (#6256) * website: submit-based plugin search on the home page, back button works on browse * website: cover Forward in the browse search history test --- packages/website/e2e/plugins.spec.ts | 64 ++++++++++++++++-- packages/website/src/plugins/browse-page.tsx | 2 +- .../website/src/plugins/plugin-search.tsx | 66 ++++++++++++++----- 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 3398a7e7d6..d41a9f3fe4 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -80,7 +80,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { await page.goto("/"); await openPlugins(page); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); await expect( page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), @@ -98,7 +98,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { test("keeps the directory's ranking window when searching", async ({ page }) => { await page.goto("/plugins?window=month"); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( "aria-current", @@ -122,13 +122,52 @@ test("clears the search with the clear button", async ({ page }) => { await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); }); -test("replaces history while typing a search", async ({ page }) => { +test("searches from the directory on submit, and Back returns to the directory", async ({ + page, +}) => { + await page.goto("/"); + await openPlugins(page); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins$/); + expect(await historyLength(page)).toBe(entries); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test("filters browse results as you type in one history entry", async ({ page }) => { await page.goto("/"); await page.goto("/plugins/all"); - await searchPlugins(page, "graphite"); - await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + await page.goBack(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); + + await page.goForward(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); + + await page.reload(); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); }); test("keeps old category links working", async ({ page }) => { @@ -262,6 +301,19 @@ async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } +async function submitSearch(page: Page, term: string) { + await searchPlugins(page, term); + await page.keyboard.press("Enter"); +} + +async function typeSearch(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term); +} + +async function historyLength(page: Page): Promise { + return page.evaluate(() => window.history.length); +} + async function expectPageMetadata(page: Page, title: string, path: string) { await expect(page).toHaveTitle(title); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index a6d77347fe..569eb44ab7 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -56,7 +56,7 @@ export function BrowsePage({
diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index 042e5e81e5..fdd3a4b5f5 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -1,37 +1,67 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { Search, X } from "lucide-react"; -import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react"; -import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links"; +import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links"; const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground"; /** - * Search box for the plugin pages. Typing replaces the URL with the browse page for the term, - * keeping `scope`'s category and sort. Without JavaScript the form submits the same URL. + * Search box for the plugin pages. Submitting opens the browse page for the term, keeping + * `scope`'s category and sort. With `live`, typing filters in place instead: the first character + * of a query pushes one history entry and later edits replace it, so Back leaves the search. + * Without JavaScript the form submits the same URL. */ -export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) { +export function PluginSearch({ + scope, + live = false, + className, +}: { + scope: BrowseQuery; + live?: boolean; + className?: string; +}) { const navigate = useNavigate(); + const router = useRouter(); const input = useRef(null); const [term, setTerm] = useState(scope.q ?? ""); - const search = useCallback( - (next: string) => { - setTerm(next); - void navigate({ - href: browseHref({ ...scope, q: next.trim() ? next : undefined }), - replace: true, - }); + // Back and Forward between results of the same page keep this box mounted; show their term. + useEffect( + () => + router.history.subscribe(({ location, action }) => { + if (action.type === "PUSH" || action.type === "REPLACE") return; + setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? ""); + }), + [router], + ); + const show = useCallback( + (next: string, { replace }: { replace: boolean }) => { + void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace }); }, [navigate, scope], ); + const edit = useCallback( + (next: string) => { + setTerm(next); + if (live) show(next, { replace: Boolean(term.trim()) }); + }, + [live, show, term], + ); const handleChange = useCallback( - (event: ChangeEvent) => search(event.target.value), - [search], + (event: ChangeEvent) => edit(event.target.value), + [edit], ); const handleClear = useCallback(() => { - search(""); + edit(""); input.current?.focus(); - }, [search]); - const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []); + }, [edit]); + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + // Live results already show the term. + if (!live) show(term, { replace: false }); + }, + [live, show, term], + ); return (
Date: Tue, 6 Oct 2026 21:56:27 +0200 Subject: [PATCH 105/281] Keep an archived agent archived when a send to it fails (#6255) * Keep an archived agent archived when a send to it fails Sending a message to an archived agent unarchives it before loading the session. When loading failed, for example because the agent's working directory was removed, the agent stayed unarchived. The send now restores the original archive timestamp when it fails, matching resume_agent. Closes #6245 * Restore the archive only when the send cannot load the agent Concurrent sends share one load, so a failed load leaves no session for the restore to close. A failure after the load no longer re-archives an agent another send may already be running. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/src/server/agent/agent-prompt.ts | 24 ++++++++++++++----- .../src/server/daemon-client.e2e.test.ts | 23 ++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 41e2d52014..0bcd46636a 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -309,18 +309,30 @@ export async function sendPromptToAgent( const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); + let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { return { disposition: "turn_started" }; } - await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); + if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { + archivedAtToRestore = record.archivedAt; + } } - await ensureAgentLoaded(params.agentId, { - agentManager: params.agentManager, - agentStorage: params.agentStorage, - logger: params.logger, - }); + try { + await ensureAgentLoaded(params.agentId, { + agentManager: params.agentManager, + agentStorage: params.agentStorage, + logger: params.logger, + }); + } catch (error) { + // A send that could not load the agent leaves it where it was: still archived. + // Concurrent sends share this load, so none of them holds a live session. + if (archivedAtToRestore) { + await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore); + } + throw error; + } if (params.sessionMode) { await params.agentManager.setAgentMode(params.agentId, params.sessionMode); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index f429cb672c..0bc3310b41 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { From cb2b569bbdfd593c16d7e34079be9eb60c8dc2c5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 22:15:58 +0200 Subject: [PATCH 106/281] Featured subtitle: hand-picked plugins that show what is possible --- packages/website/src/routes/plugins/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 88ab7214e6..231c3eafa9 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -133,7 +133,7 @@ function PluginsPage() {
Featured -

Hand-picked by the maintainer.

+

Hand-picked plugins that show what is possible

{featured.map((plugin) => ( From 66b2567be3b4c942a6e7f42d8b495e28cf416e99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 22:20:10 +0200 Subject: [PATCH 107/281] Featured: subtitle and a See all plugins link --- packages/website/src/routes/plugins/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 231c3eafa9..d686b98a4e 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -131,10 +131,16 @@ function PluginsPage() {
{featured.length > 0 && ( -
- Featured -

Hand-picked plugins that show what is possible

-
+ +
+ Featured +

A selection of hand picked plugins

+
+ + See all plugins + + +
{featured.map((plugin) => ( From 72c180aaf733003d58e46faa605145b2a4fddfc3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 22:23:50 +0200 Subject: [PATCH 108/281] website: plugins section spacing with margins, not flex gaps PluginSection takes its header as a prop and puts the content 32px below it with a margin. Sections on the index are 56px apart via space-y. The browse page h1 sits 32px above the sort tabs, matching the index headings. --- packages/website/src/plugins/browse-page.tsx | 5 +- packages/website/src/plugins/section.tsx | 11 ++- packages/website/src/routes/plugins/index.tsx | 91 ++++++++++++------- 3 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 569eb44ab7..95a925db2b 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -60,7 +60,7 @@ export function BrowsePage({
-
+

{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} @@ -73,8 +73,7 @@ export function BrowsePage({ )}

- - + }> {results.length === 0 && query.q && (

No plugins match.

diff --git a/packages/website/src/plugins/section.tsx b/packages/website/src/plugins/section.tsx index 690a758fde..f873457a75 100644 --- a/packages/website/src/plugins/section.tsx +++ b/packages/website/src/plugins/section.tsx @@ -1,20 +1,23 @@ import type { ReactNode } from "react"; -/** A block of a plugins page: its header, then its content one fixed gap below. */ +/** A block of a plugins page: its header, then its content 32px below. */ export function PluginSection({ id, labelledBy, - className = "", + className, + header, children, }: { id?: string; labelledBy?: string; className?: string; + header: ReactNode; children: ReactNode; }) { return ( -
- {children} +
+ {header} +
{children}
); } diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index d686b98a4e..b694b2147a 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -128,19 +128,31 @@ function PluginsPage() {
-
+
{featured.length > 0 && ( - - -
- Featured -

A selection of hand picked plugins

-
- - See all plugins - - -
+ +
+ Featured +

+ A selection of hand picked plugins +

+
+ + See all plugins + + + + } + >
{featured.map((plugin) => ( @@ -149,14 +161,21 @@ function PluginsPage() { )} - - - What’s new - - See all - - - + + What’s new + + See all + + + + } + >
{newest.map((plugin) => ( @@ -164,8 +183,10 @@ function PluginsPage() {
- - Categories + Categories} + >
{CATEGORIES.map((category) => { const Icon = CATEGORY_ICONS[category.slug]; @@ -192,19 +213,21 @@ function PluginsPage() { id="most-installed" labelledBy="most-installed-title" className="scroll-mt-8" + header={ + + + + Most installed + + + + + + } > - - - - Most installed - - - - -
{top.map((plugin, index) => ( Date: Tue, 6 Oct 2026 22:30:39 +0200 Subject: [PATCH 109/281] Revert "website: plugins section spacing with margins, not flex gaps" This reverts commit 72c180aaf733003d58e46faa605145b2a4fddfc3. --- packages/website/src/plugins/browse-page.tsx | 5 +- packages/website/src/plugins/section.tsx | 11 +-- packages/website/src/routes/plugins/index.tsx | 91 +++++++------------ 3 files changed, 41 insertions(+), 66 deletions(-) diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 95a925db2b..569eb44ab7 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -60,7 +60,7 @@ export function BrowsePage({
-
+

{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} @@ -73,7 +73,8 @@ export function BrowsePage({ )}

- }> + + {results.length === 0 && query.q && (

No plugins match.

diff --git a/packages/website/src/plugins/section.tsx b/packages/website/src/plugins/section.tsx index f873457a75..690a758fde 100644 --- a/packages/website/src/plugins/section.tsx +++ b/packages/website/src/plugins/section.tsx @@ -1,23 +1,20 @@ import type { ReactNode } from "react"; -/** A block of a plugins page: its header, then its content 32px below. */ +/** A block of a plugins page: its header, then its content one fixed gap below. */ export function PluginSection({ id, labelledBy, - className, - header, + className = "", children, }: { id?: string; labelledBy?: string; className?: string; - header: ReactNode; children: ReactNode; }) { return ( -
- {header} -
{children}
+
+ {children}
); } diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index b694b2147a..d686b98a4e 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -128,31 +128,19 @@ function PluginsPage() {
-
+
{featured.length > 0 && ( - -
- Featured -

- A selection of hand picked plugins -

-
- - See all plugins - - - - } - > + + +
+ Featured +

A selection of hand picked plugins

+
+ + See all plugins + + +
{featured.map((plugin) => ( @@ -161,21 +149,14 @@ function PluginsPage() { )} - - What’s new - - See all - - - - } - > + + + What’s new + + See all + + +
{newest.map((plugin) => ( @@ -183,10 +164,8 @@ function PluginsPage() {
- Categories} - > + + Categories
{CATEGORIES.map((category) => { const Icon = CATEGORY_ICONS[category.slug]; @@ -213,21 +192,19 @@ function PluginsPage() { id="most-installed" labelledBy="most-installed-title" className="scroll-mt-8" - header={ - - - - Most installed - - - - - - } > + + + + Most installed + + + + +
{top.map((plugin, index) => ( Date: Tue, 6 Oct 2026 22:30:40 +0200 Subject: [PATCH 110/281] website: 80px between the site header and the page content --- packages/website/src/components/site-shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/components/site-shell.tsx b/packages/website/src/components/site-shell.tsx index 662f9f50f9..2c0a38f795 100644 --- a/packages/website/src/components/site-shell.tsx +++ b/packages/website/src/components/site-shell.tsx @@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) { return (
-
+
{children} From 50cf244e865012b79c38ead4cd5814f420c56651 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 22:31:42 +0200 Subject: [PATCH 111/281] website e2e: expect the current Featured subtitle --- packages/website/e2e/plugins.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index d41a9f3fe4..da668a6c60 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -451,7 +451,7 @@ test.describe("registry fixture layout", () => { await openPlugins(page); await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); const featured = page.getByRole("region", { name: "Featured" }); - await expect(featured).toContainText("Hand-picked by the maintainer."); + await expect(featured).toContainText("A selection of hand picked plugins"); await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ /Dracula/, /Herald/, From 3f1a04304d53b771cd4b633f40af8fef5aca73b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 21:49:25 +0100 Subject: [PATCH 112/281] website: plugin gallery peeks a third tile, viewer steps with arrows (#6261) * website: gallery peeks a third tile, viewer steps with arrows With three or more items the strip sizes tiles at 42% from md up so a third tile is cut at the right edge. The viewer gains Previous and Next buttons and Left/Right arrow keys, wrapping at the ends; moving away from a video unmounts it, which stops playback. * website: format the plugins directory route --- packages/website/e2e/plugins.spec.ts | 66 +++++++++ .../website/src/plugins/media-gallery.tsx | 127 ++++++++++++++---- packages/website/src/routes/plugins/index.tsx | 9 +- 3 files changed, 173 insertions(+), 29 deletions(-) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index da668a6c60..9d2b3af8aa 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -424,6 +424,61 @@ test.describe("registry fixture layout", () => { await expect(viewer).toBeHidden(); }); + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; await page.route(plugin.media[0], (route) => route.abort()); @@ -432,6 +487,7 @@ test.describe("registry fixture layout", () => { const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); await expect(video).toBeVisible(); await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); }); test("shows the plugin tile on cards when its media has no image", async ({ page }) => { @@ -481,6 +537,16 @@ function galleryTiles(links: Element[]) { })); } +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + /** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ function viewerFill(media: HTMLElement) { const box = media.getBoundingClientRect(); diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx index 5fe47c4631..ac25a6f53e 100644 --- a/packages/website/src/plugins/media-gallery.tsx +++ b/packages/website/src/plugins/media-gallery.tsx @@ -1,8 +1,9 @@ import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; -import { Play, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, Play, X } from "lucide-react"; import { type CSSProperties, + type KeyboardEvent, type MouseEvent, type SyntheticEvent, useCallback, @@ -12,11 +13,17 @@ import { } from "react"; const TILE_CLASS = - "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"; + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]"; +// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls. +const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`; +const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`; // The viewer's room: media never exceeds this box, which leaves a margin around it. const VIEW_WIDTH = "90vw"; const VIEW_HEIGHT = "85vh"; const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; +const VIEWER_BUTTON_CLASS = + "fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"; +const ARROW_STEPS: Partial> = { ArrowLeft: -1, ArrowRight: 1 }; interface MediaItem { url: string; @@ -28,46 +35,96 @@ interface MediaItem { /** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ export function MediaGallery({ name, media }: { name: string; media: string[] }) { const dialog = useRef(null); - const [viewing, setViewing] = useState(null); - const open = useCallback((item: MediaItem) => { - setViewing(item); - dialog.current?.showModal(); - }, []); + const entries = useMemo( + () => media.map((url, index) => mediaEntry(name, url, index)), + [name, media], + ); + const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]); + const [viewing, setViewing] = useState(null); + const item = viewing === null ? undefined : items[viewing]; + const open = useCallback( + (opened: MediaItem) => { + setViewing(items.indexOf(opened)); + dialog.current?.showModal(); + }, + [items], + ); const close = useCallback(() => dialog.current?.close(), []); // Unmounting the viewed media stops a playing video. const handleClose = useCallback(() => setViewing(null), []); const closeOnBackdrop = useCallback((event: MouseEvent) => { if (event.target === event.currentTarget) event.currentTarget.close(); }, []); + const step = useCallback( + (by: number) => + setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)), + [items.length], + ); + const showPrevious = useCallback(() => step(-1), [step]); + const showNext = useCallback(() => step(1), [step]); + // Captured so arrow keys move between items even while a focused video would seek. + const stepWithArrowKeys = useCallback( + (event: KeyboardEvent) => { + const by = ARROW_STEPS[event.key]; + if (by === undefined || items.length < 2) return; + event.preventDefault(); + step(by); + }, + [items.length, step], + ); if (media.length === 0) return null; + const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS; return ( <>
- {media.map((url, index) => { - const source = pluginOverviewUrl(url); - if (!source) return

{url}

; - const kind = pluginMediaKind(source); - const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`; - return ; - })} + {entries.map((entry) => + typeof entry === "string" ? ( +

{entry}

+ ) : ( + + ), + )}
+ {/* Focusable so arrow keys still reach it after a click on non-focusable media. */} - {viewing?.kind === "image" && ( - {viewing.label} + {item?.kind === "image" && ( + {item.label} + )} + {item?.kind === "video" && } + {items.length > 1 && ( + <> + + + )} - {viewing?.kind === "video" && } @@ -76,6 +133,18 @@ export function MediaGallery({ name, media }: { name: string; media: string[] }) ); } +/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */ +function mediaEntry(name: string, url: string, index: number): MediaItem | string { + const source = pluginOverviewUrl(url); + if (!source) return url; + const kind = pluginMediaKind(source); + return { + url: source, + kind, + label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`, + }; +} + /** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ function ViewerVideo({ url, label }: { url: string; label: string }) { const [aspectRatio, setAspectRatio] = useState(null); @@ -105,19 +174,23 @@ function ViewerVideo({ url, label }: { url: string; label: string }) { } function MediaTile({ - url, - kind, - label, + item, + className, onOpen, -}: MediaItem & { onOpen: (item: MediaItem) => void }) { +}: { + item: MediaItem; + className: string; + onOpen: (item: MediaItem) => void; +}) { + const { url, kind, label } = item; const handleClick = useCallback( (event: MouseEvent) => { // Modified clicks keep the link's own behavior, such as opening the file in a new tab. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); - onOpen({ url, kind, label }); + onOpen(item); }, - [onOpen, url, kind, label], + [onOpen, item], ); return ( {kind === "image" ? (
Featured -

A selection of hand picked plugins

+

+ A selection of hand picked plugins +

-
+ See all plugins From bdc58fb07c6610181c42b4a981c6a8767ae124ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:18:12 +0200 Subject: [PATCH 113/281] feat(website): plugins hero, author and description on cards, install counts with an icon The plugins page opens with the directory copy, plugin and author counts, Submit a plugin and Read the docs, and search. Featured and What's new cards show the description and the author with avatar; the trailing slot carries installs on Featured and the added date on What's new. Every install count on the site is the same download icon and number. --- packages/website/src/plugins/author-link.tsx | 1 + packages/website/src/plugins/browse-page.tsx | 13 +-- .../website/src/plugins/contribute-links.tsx | 23 ++++-- packages/website/src/plugins/hero.tsx | 38 +++++++++ .../website/src/plugins/install-count.tsx | 13 +++ packages/website/src/plugins/plugin-card.tsx | 80 +++++++++---------- packages/website/src/plugins/plugin-tile.tsx | 1 + .../website/src/plugins/thumbnails.test.ts | 28 +++---- .../website/src/routes/plugins/$owner.tsx | 5 +- .../src/routes/plugins/$owner_.$slug.tsx | 11 ++- packages/website/src/routes/plugins/index.tsx | 40 +++++----- 11 files changed, 157 insertions(+), 96 deletions(-) create mode 100644 packages/website/src/plugins/hero.tsx create mode 100644 packages/website/src/plugins/install-count.tsx diff --git a/packages/website/src/plugins/author-link.tsx b/packages/website/src/plugins/author-link.tsx index 769fc9cbf5..3a2be803f3 100644 --- a/packages/website/src/plugins/author-link.tsx +++ b/packages/website/src/plugins/author-link.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react"; import { type Author, authorAvatarUrl } from "./registry"; const SIZE_CLASS = { + xs: "h-4 w-4 text-[8px]", sm: "h-5 w-5 text-[10px]", lg: "h-14 w-14 text-xl", } as const; diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 569eb44ab7..1b91c0a94d 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -3,6 +3,7 @@ import { SiteShell } from "~/components/site-shell"; import { ContributeLinks } from "./contribute-links"; import type { InstallCounts } from "./installs"; import { type BrowseQuery, browseHref } from "./links"; +import { InstallCount } from "./install-count"; import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; import { PluginSearch } from "./plugin-search"; import { PluginSection } from "./section"; @@ -93,13 +94,13 @@ export function BrowsePage({
{results.map((plugin) => query.sort === "new" ? ( - + + {addedAgo(plugin, now)} + ) : ( - + + + ), )}
diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index 3aeb33a16b..705615770d 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -21,7 +21,21 @@ export function ContributeLinks() { ); } -/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */ +/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */ +export function ContributeButtons() { + return ( + <> + + Submit a plugin + + + Read the docs + + + ); +} + +/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */ export function ContributeSection() { return (
); diff --git a/packages/website/src/plugins/hero.tsx b/packages/website/src/plugins/hero.tsx new file mode 100644 index 0000000000..9bd9f5ba31 --- /dev/null +++ b/packages/website/src/plugins/hero.tsx @@ -0,0 +1,38 @@ +import { ContributeButtons } from "./contribute-links"; +import type { BrowseQuery } from "./links"; +import { PluginSearch } from "./plugin-search"; + +interface PluginsHeroProps { + pluginCount: number; + authorCount: number; + /** Where the search box sends its term. */ + searchScope: BrowseQuery; +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */ +export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) { + return ( +
+

+ Plugins + + {plural(pluginCount, "plugin")} by {plural(authorCount, "author")} + +

+

+ Extend Paseo with plugins that can add new themes, workspace panels and new functionality. + Plugins work on web, desktop and mobile. +

+
+
+ +
+ +
+
+ ); +} diff --git a/packages/website/src/plugins/install-count.tsx b/packages/website/src/plugins/install-count.tsx new file mode 100644 index 0000000000..4867415596 --- /dev/null +++ b/packages/website/src/plugins/install-count.tsx @@ -0,0 +1,13 @@ +import { Download } from "lucide-react"; +import { formatInstalls } from "./registry"; + +/** An install count as a download icon and the number; inherits the surrounding text style. */ +export function InstallCount({ count }: { count: number }) { + return ( + + + {formatInstalls(count)} + installs + + ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index 488b97ac55..b68ae654db 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,8 +1,10 @@ -import { Download } from "lucide-react"; +import type { ReactNode } from "react"; +import { AuthorAvatar } from "./author-link"; +import { InstallCount } from "./install-count"; import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; import { PluginTile } from "./plugin-tile"; -import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry"; +import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry"; export const PLUGIN_GRID_CLASS = "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; @@ -32,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) { ); } -/** What's new card: screenshot, name, and when it was added. */ -export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) { +/** + * What's new and Featured card, in three rows under the screenshot: tile and name, description, + * then author and `children`, such as when the plugin was added or its install count. + */ +export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { + const author = getAuthor(plugin); return ( -
- -
-

{plugin.name}

-

Added {added}

-
+
+ +

{plugin.name}

+

+ {plugin.description} +

+

+ + {author.name} + {children !== undefined && ( + <> + · + {children} + + )} +

); } -/** Grid card: screenshot, name, one muted stat, and a two-line description. */ -export function PluginCard({ - plugin, - installs, - added, -}: { - plugin: Plugin; - /** Shown with a download icon; otherwise `added` is shown. */ - installs?: number; - added?: string; -}) { +/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */ +export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { return ( - - {formatInstalls(installs)} + + + ); } diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx index c0fc81d483..5e2dc9c31b 100644 --- a/packages/website/src/plugins/plugin-tile.tsx +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -2,6 +2,7 @@ import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import type { Plugin } from "./registry"; const SIZE_CLASS = { + xs: "h-6 w-6 rounded-md text-xs", sm: "h-9 w-9 rounded-lg text-sm", lg: "h-14 w-14 rounded-xl text-xl", } as const; diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts index 8769995ca3..fb9a571b93 100644 --- a/packages/website/src/plugins/thumbnails.test.ts +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -56,20 +56,20 @@ describe("plugin card thumbnails", () => { expect(html).not.toContain("/plugins/thumb/"); }); - it.each([PluginCard, NewPluginCard])( - "renders a thumbnail instead of downloading the original screenshot (%s)", - (Card) => { - const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" })); - expect(html).toContain( - `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`, - ); - expect(html).toContain( - `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`, - ); - expect(html).toContain('loading="lazy"'); - expect(html).toContain('decoding="async"'); - }, - ); + it.each([ + ["PluginCard", () => createElement(PluginCard, { plugin }, "today")], + ["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")], + ])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => { + const html = renderToStaticMarkup(card()); + expect(html).toContain( + `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`, + ); + expect(html).toContain( + `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`, + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + }); }); function thumbnailRequest( diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index 0345fa1355..89c4f185ef 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -16,6 +16,7 @@ import { } from "~/plugins"; import { AuthorAvatar } from "~/plugins/author-link"; import { PluginsNotFound } from "~/plugins/not-found"; +import { InstallCount } from "~/plugins/install-count"; import { PluginCard } from "~/plugins/plugin-card"; import "~/styles.css"; @@ -82,7 +83,9 @@ function AuthorPage() {
{plugins.map((plugin) => ( - + + + ))}
diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index 9ea61f3248..5bb1c73a1e 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -6,7 +6,6 @@ import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; import { firstMediaImage, - formatInstalls, getAuthor, getCategory, getRegistry, @@ -16,7 +15,8 @@ import { pluginVersion, readmeBody, } from "~/plugins"; -import { AuthorLink } from "~/plugins/author-link"; +import { AuthorAvatar, AuthorLink } from "~/plugins/author-link"; +import { InstallCount } from "~/plugins/install-count"; import { categoryHref } from "~/plugins/links"; import { MediaGallery } from "~/plugins/media-gallery"; import { PluginsNotFound } from "~/plugins/not-found"; @@ -48,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ const META_LINK_CLASS = "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const AUTHOR_LINK_CLASS = + "inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; function PluginPage() { const { plugin, installs } = Route.useLoaderData(); @@ -67,7 +69,8 @@ function PluginPage() { {installCommand(plugin)}
- + + {author.name} {category && ( @@ -75,7 +78,7 @@ function PluginPage() { {category.label} )} - {formatInstalls(installs)} installs + {pluginVersion(plugin)} Source diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 155796a35c..08c31705de 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -26,8 +26,10 @@ import { type InstallWindow, mostInstalled, newestFirst, + pluginOwner, } from "~/plugins"; import { ContributeSection } from "~/plugins/contribute-links"; +import { PluginsHero } from "~/plugins/hero"; import { type BrowseQuery, browseHref, @@ -37,10 +39,9 @@ import { parseSearchTerm, parseSort, parseWindow, - SUBMIT_URL, } from "~/plugins/links"; +import { InstallCount } from "~/plugins/install-count"; import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; -import { PluginSearch } from "~/plugins/plugin-search"; import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section"; import { WindowSwitch } from "~/plugins/window-switch"; import "~/styles.css"; @@ -60,7 +61,6 @@ const CATEGORY_ICONS: Record = { utils: Wrench, }; -const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground"; // What's new and Featured cards: a swipeable row on phones, a grid from `sm` up. const CARD_ROW_CLASS = "-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4"; @@ -110,31 +110,23 @@ function PluginsPage() { [], ); const searchScope = useMemo(() => ({ sort: "installs", window }), [window]); + const authorCount = new Set(plugins.map(pluginOwner)).size; return ( -
-

- Plugins - - {plugins.length} - -

- -
+ -
+
{featured.length > 0 && (
Featured -

+

A selection of hand picked plugins

@@ -142,13 +134,15 @@ function PluginsPage() { href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS} > - See all plugins + See all
{featured.map((plugin) => ( - + + + ))}
@@ -164,7 +158,9 @@ function PluginsPage() {
{newest.map((plugin) => ( - + + {addedAgo(plugin, now)} + ))}
From aedba5ec2770c0169e25c91de4877406bc91b5aa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:19:16 +0200 Subject: [PATCH 114/281] fix(website): plugin page content spans the same width as the header and footer The page capped itself at max-w-3xl inside the site shell, so the install command, gallery, and README stopped short of the nav's right edge. --- .../src/routes/plugins/$owner_.$slug.tsx | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index 5bb1c73a1e..efd7369a94 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -59,44 +59,42 @@ function PluginPage() { return ( -
- - ← Plugins - -

{plugin.name}

-

{plugin.description}

-
- {installCommand(plugin)} -
-
- - - {author.name} - - {category && ( - - {category.label} - - )} - - {pluginVersion(plugin)} - - Source + + ← Plugins + +

{plugin.name}

+

{plugin.description}

+
+ {installCommand(plugin)} +
+
+ + + {author.name} + + {category && ( + + {category.label} + + )} + + {pluginVersion(plugin)} + + Source + + + {npm && ( + + npm - {npm && ( - - npm - - - )} -
+ )} +
- + -
- {readmeBody(plugin.readme)} -
+
+ {readmeBody(plugin.readme)}
); From f715221de8af0289e9dfc7493e16e3c6325b772c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:26:35 +0200 Subject: [PATCH 115/281] feat(website): plugins page copy describes browsing community plugins --- packages/website/src/plugins/hero.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/website/src/plugins/hero.tsx b/packages/website/src/plugins/hero.tsx index 9bd9f5ba31..0683a0d6e0 100644 --- a/packages/website/src/plugins/hero.tsx +++ b/packages/website/src/plugins/hero.tsx @@ -24,8 +24,7 @@ export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHe

- Extend Paseo with plugins that can add new themes, workspace panels and new functionality. - Plugins work on web, desktop and mobile. + Browse community plugins that extend Paseo with new functionality.

From d4ea77b910b318942dee063291c0650bf99b2267 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:26:35 +0200 Subject: [PATCH 116/281] fix(website): plugin install command block fits the command --- packages/website/src/routes/plugins/$owner_.$slug.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index efd7369a94..2000067e00 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -64,7 +64,7 @@ function PluginPage() {

{plugin.name}

{plugin.description}

-
+
{installCommand(plugin)}
From 1463396ebca49782cfa8aad036b6b3a50e196714 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:50:27 +0200 Subject: [PATCH 117/281] fix(website): plugin search ranks name matches first Search filtered by substring and the browse page then sorted by installs, so an exact name like "OMP" landed behind plugins that only mentioned it in a description. Results now rank by the field matched (name, author, description), then match quality from the shared text matcher, with the tab's order breaking ties. --- packages/website/src/plugins/browse-page.tsx | 11 ++--- packages/website/src/plugins/registry.test.ts | 26 ++++++++++++ packages/website/src/plugins/registry.ts | 42 +++++++++++++++---- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 1b91c0a94d..274c68a5c0 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -47,10 +47,11 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const matches = query.q ? searchPlugins(plugins, query.q) : plugins; - const scoped = category ? getPluginsInCategory(matches, category.slug) : matches; - const results = - query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window); + const ordered = + query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window); + // Search ranks by relevance; the tab's order breaks ties. + const matches = query.q ? searchPlugins(ordered, query.q) : ordered; + const results = category ? getPluginsInCategory(matches, category.slug) : matches; const title = category?.label ?? "All plugins"; const clearHref = browseHref({ ...query, q: undefined }); return ( @@ -65,7 +66,7 @@ export function BrowsePage({

{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} - {scoped.length} + {results.length}

{query.q && ( diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index ca678f6453..b6135351b3 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -228,6 +228,32 @@ describe("plugin registry", () => { expect(searchPlugins(plugins, " ")).toEqual(plugins); expect(searchPlugins(plugins, "nothing")).toEqual([]); }); + it("ranks name, then author, then description matches, keeping the given order on ties", () => { + const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({ + ...plugin, + id, + name, + description, + author: { github }, + }); + // Given in install order: description-only matches first, the exact name last. + const plugins = [ + named("acme/editor", "Remote Editor", "Composer pill to open a workspace"), + named("acme/kit", "PromptKit", "Rewrite prompts"), + named("omp/tools", "Tools", "Helpers", "omp"), + named("acme/history", "History", "Inspect raw composer records"), + named("acme/omp", "OMP", "Paseo integration for OMP"), + ]; + expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([ + "acme/omp", + "acme/kit", + "omp/tools", + "acme/editor", + "acme/history", + ]); + expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]); + expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]); + }); it("strips the README title and quoted description that the page already shows", () => { expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n"); diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index c06ea3dc5c..13bfc0d1ca 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { CATEGORIES, type Category, type CategorySlug } from "./categories"; import type { InstallCounts, InstallWindow } from "./installs"; export { CATEGORIES, type Category, type CategorySlug }; @@ -66,16 +67,39 @@ export function formatInstalls(count: number): string { const k = count / 1000; return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; } -/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */ +/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */ +function searchFieldGroups(plugin: Plugin): string[][] { + const author = getAuthor(plugin); + const name = [plugin.name, plugin.id.split("/")[1]]; + const owner = [author.name, author.github, plugin.id]; + return [name, owner, [plugin.description], [...name, ...owner, plugin.description]]; +} +interface SearchRank { + field: number; + tier: number; +} +function searchRank(plugin: Plugin, term: string): SearchRank | null { + const groups = searchFieldGroups(plugin); + for (let field = 0; field < groups.length; field += 1) { + const score = scoreTextFields(term, groups[field], { subsequence: false }); + if (score) return { field, tier: score.tier }; + } + return null; +} +/** + * Plugins whose name, author, ID, or description contains every word of the term, ignoring case. + * Name matches come first, then author, then description; within each, exact words beat partial + * ones, and equal matches keep the given order. + */ export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { - const needle = term.trim().toLowerCase(); - if (!needle) return plugins; - return plugins.filter((plugin) => - [plugin.name, plugin.description, plugin.id, getAuthor(plugin).name] - .join(" ") - .toLowerCase() - .includes(needle), - ); + if (!term.trim()) return plugins; + return plugins + .flatMap((plugin) => { + const rank = searchRank(plugin, term); + return rank ? [{ plugin, rank }] : []; + }) + .sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier) + .map(({ plugin }) => plugin); } /** The featured IDs that name a listed plugin, in the registry's order, each once. */ export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] { From 2d85fe4e04268f845ea4153e66e7e6d37f4e4963 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:18:24 +0100 Subject: [PATCH 118/281] feat(plugins): declare display metadata in plugin manifests (#6263) * feat(plugins): declare display metadata in plugin manifests * style(website): format plugin page description * fix(plugins): preserve default names and clarify registry follow-up * test: fix stale plugin card selectors and terminal marker race --- .../cli/src/commands/plugin/scaffold.test.ts | 2 + packages/cli/src/commands/plugin/scaffold.ts | 12 +++- .../protocol/src/messages.plugins.test.ts | 21 +++++++ packages/protocol/src/messages.ts | 3 + .../src/server/plugins/index.posix.test.ts | 26 ++++++-- packages/server/src/server/plugins/index.ts | 7 ++- .../src/server/plugins/manifest.test.ts | 63 +++++++++++++++++++ .../server/src/server/plugins/manifest.ts | 35 ++++++++--- .../terminal/worker-terminal-manager.test.ts | 4 +- packages/website/e2e/plugins.spec.ts | 22 ++++--- packages/website/src/routes/plugins/index.tsx | 4 +- public-docs/plugins/publishing.md | 36 ++++++++++- public-docs/plugins/reference.md | 24 +++++-- skills/paseo-plugin/SKILL.md | 3 +- 14 files changed, 224 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index c69ca4c197..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -93,6 +93,8 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index f0f1fe3b93..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory( const files = new Map([ [ "paseo-plugin.json", - `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, ], ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 41b9ca3f74..6f5e4abd3f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index f9592597f1..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -419,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -427,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -443,11 +449,23 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 5567e23dd5..6c4af29e62 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -216,7 +216,12 @@ export class PluginService { }), }; const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); - if (manifest?.description) item.description = manifest.description; + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } item.installation = await this.managedSources ?.describe(id, source.path) .catch(() => undefined); diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer; diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..efccd3dfb6 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 9d2b3af8aa..3d25f942b9 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -8,6 +8,13 @@ async function openPlugins(page: Page) { await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + test("browses from the directory into a category, a plugin, and its author", async ({ page, context, @@ -508,11 +515,7 @@ test.describe("registry fixture layout", () => { await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); const featured = page.getByRole("region", { name: "Featured" }); await expect(featured).toContainText("A selection of hand picked plugins"); - await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ - /Dracula/, - /Herald/, - /launchd Jobs/, - ]); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); }); test("lists the nine categories in order with counts, and the newest plugins first", async ({ @@ -524,9 +527,12 @@ test.describe("registry fixture layout", () => { CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), ); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); - await expect( - page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), - ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); }); }); diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 08c31705de..505b8177fc 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -126,9 +126,7 @@ function PluginsPage() {
Featured -

- A selection of hand picked plugins -

+

A selection of hand picked plugins

=0.11.0" } +} +``` + +All three fields are optional. `name` gives the registry and website a display name without +changing the installation ID. When omitted, the registry falls back to a humanized ID. +Registry and website display requires the registry builder to read these fields from the pinned +manifest. That update is a separate follow-up; until it lands, these fields are exposed only +through the daemon's installed-plugin list. +`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos +inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use +forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files) +for validation rules. + +Commit local assets alongside the manifest at the pinned revision. For npm, add their directory +(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`. +The scaffold documents these fields in its manifest's `$comment`; add your display name and +assets before publishing. + +Manifests using these fields fail to install on daemons before 0.11.0. Set +`requirements.paseo` to `>=0.11.0` or a narrower supported range. + ## Your listing page Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. @@ -170,8 +203,7 @@ The repository overview takes precedence over a registry import stopgap. Online fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records that already carry `plugins//.md` in the registry repository. Every version bump requires a repository overview and removes the stopgap in the same PR. A bump without the -repository overview fails validation. Neither README files nor a `paseo-listing.json` -`readme` override supplies the overview. +repository overview fails validation. README files do not supply the overview. Author overviews and registry stopgaps follow the same content contract, in this order. Choose headings only when they help; length follows complexity. A theme needs one paragraph. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 2a8714e4d7..1a1440ad97 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ---------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | ----------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + +Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` +segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example. +Paseo validates the references without opening local assets or fetching URLs. + +Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside +`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check. +Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject +these fields during installation. ### Requirements diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index f229bf7a3a..e511a7e12f 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -657,8 +657,7 @@ The repository overview takes precedence over a registry import stopgap. Online fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records that already carry `plugins//.md` in the registry repository. Every version bump requires a repository overview and removes the stopgap in the same PR. A bump without the -repository overview fails validation. Neither README files nor a `paseo-listing.json` -`readme` override supplies the overview. +repository overview fails validation. README files do not supply the overview. Author overviews and registry stopgaps follow the same content contract, in this order. Choose headings only when they help; length follows complexity. A theme needs one paragraph. From 93bfb34e2135b0e40dfd98ae37e64597a7b89d15 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 00:59:14 +0100 Subject: [PATCH 119/281] feat(website): copy states what Paseo does, plugins section and comparison tables (#6270) The homepage, README, sponsor copy and comparison pages now say plainly what Paseo is: a one-click desktop app with the daemon bundled, a native mobile app with full parity, parallel agents in worktrees, and plugins that add panels and functionality on desktop, web and mobile. Every comparison page uses one table with the same rows so readers can compare products row by row. The product columns use a fixed layout so column widths no longer follow the longest cell. Sponsor copy opens with the project's size before the one-person fact, so a reader does not take "one person" to mean a side project. --- README.md | 28 ++- packages/website/e2e/plugins.spec.ts | 2 +- .../src/components/alternative-page.tsx | 4 +- .../website/src/components/landing-page.tsx | 168 ++++++++++-------- .../website/src/components/sponsorship.tsx | 22 +-- .../content/alternatives/claude-desktop.md | 50 +++--- .../src/content/alternatives/codex-app.md | 48 ++--- .../src/content/alternatives/conductor.md | 46 +++-- .../src/content/alternatives/happy-coder.md | 46 +++-- .../src/content/alternatives/openchamber.md | 45 +++-- .../content/alternatives/opencode-desktop.md | 48 ++--- .../website/src/content/alternatives/orca.md | 55 +++--- .../src/content/alternatives/superset.md | 46 +++-- packages/website/src/routes/index.tsx | 6 +- packages/website/src/routes/sponsor.tsx | 2 +- packages/website/src/styles.css | 18 ++ 16 files changed, 369 insertions(+), 265 deletions(-) diff --git a/README.md b/README.md index aa0a841e7e..a8fe3f5b25 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,11 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. - **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. @@ -49,11 +52,18 @@ Run agents in parallel on your own machines. Ship from your phone or your desk. ## Plugins -Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted -TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install `. +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. -Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon -machine and inside connected clients; install only code you trust. +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. ## Getting Started @@ -77,16 +87,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -197,7 +207,7 @@ npm run typecheck ## Sponsors -Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 3d25f942b9..1efc20115c 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -353,7 +353,7 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ await expect( page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), ).toHaveCount(0); - await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( "href", "https://paseo.cafe", ); diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx index ec583fefaf..3322f869e1 100644 --- a/packages/website/src/components/alternative-page.tsx +++ b/packages/website/src/components/alternative-page.tsx @@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) { return ( - {page.content} +
+ {page.content} +
); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a1fcc18ef3..a5b573a1d6 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -4,13 +4,12 @@ import { ArrowRight, Blocks, Bot, - BookOpen, Braces, - Coffee, Compass, ExternalLink, Gem, GitFork, + LayoutPanelLeft, Laptop, Lock, Merge, @@ -19,6 +18,7 @@ import { Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -477,7 +477,7 @@ function TurnkeySection() {

Desktop app

- The one click experience, download the app and it just works + The one click experience, download the app and it just works, no server required

@@ -487,8 +487,8 @@ function TurnkeySection() {
@@ -793,92 +793,105 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - -
- - + +
+ + +
+
+ {PLUGIN_CAPABILITIES.map((capability) => ( + + ))} +
+
+ +
+

Paseo is Apache 2.0.

+ + + Fork the repo + + +
); } -interface ExtensibleCardLink { - href: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - external?: boolean; - /** Accent links stand out without hover, for destinations worth noticing. */ - accent?: boolean; -} +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; -const PLUGIN_CARD_LINKS: ReadonlyArray = [ - { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - PLUGINS_LINKED - ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } - : { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, -]; - -const FORK_CARD_LINKS: ReadonlyArray = [ +const PLUGIN_CAPABILITIES: ReadonlyArray<{ + icon: LucideIcon; + title: string; + description: string; +}> = [ { - href: "https://github.com/getpaseo/paseo", - label: "View the repository", - icon: GitHubIcon, - external: true, + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: + "Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used the same way as the built-in ones", }, ]; -function ExtensibleCard({ +function PluginCapabilityCard({ icon: Icon, title, description, - links, }: { icon: LucideIcon; title: string; description: string; - links: ReadonlyArray; }) { return ( -
-
- +
+
+
-

{title}

+

{title}

{description}

-
- {links.map((link) => ( - - - {link.label} - {link.external ? : null} - - ))} -
); } @@ -902,8 +915,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: { icon: Merge, title: "Unified", - description: - "Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.", + description: "Every coding agent, used the same way. Switch providers any time.", }, { icon: Blocks, @@ -923,17 +935,17 @@ function PhilosophySection() { title="Philosophy" description="What Paseo stands for, and what every feature is built on" > -
+
{PRINCIPLES.map((principle) => (
-
- +
+ +

{principle.title}

-

{principle.title}

-

{principle.description}

+

{principle.description}

))}
diff --git a/packages/website/src/components/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx index 449742bffb..e3420aa36f 100644 --- a/packages/website/src/components/sponsorship.tsx +++ b/packages/website/src/components/sponsorship.tsx @@ -53,9 +53,10 @@ const MAINTAINER_LINK = ( export function FounderNote() { return (
+

Paseo is an independent project used by tens of thousands of developers daily.

- I build Paseo on my own. There are no investors, no board and no company behind it, and I - have turned down funding offers to keep it that way. + It is built by one person, full time, with no investors, no board and no company behind it. + I have turned down funding offers to keep it that way.

A tool that sits between you and your code, your keys and your machines has to stay neutral, @@ -67,7 +68,7 @@ export function FounderNote() { Paseo Hub - , an optional hosted service. Your support is what lets me work on Paseo full time. + , an optional hosted service. Your support is what lets me keep working on it.

{MAINTAINER_LINK}, maintainer

@@ -79,7 +80,6 @@ interface BackingOption { name: string; icon: React.ComponentType<{ className?: string }>; external: boolean; - primary?: boolean; } const BACKING_OPTIONS: ReadonlyArray = [ @@ -88,7 +88,6 @@ const BACKING_OPTIONS: ReadonlyArray = [ name: "GitHub Sponsors", icon: GitHubIcon, external: true, - primary: true, }, { href: OPEN_COLLECTIVE_URL, @@ -127,11 +126,7 @@ export function BackingOptions() { key={option.href} href={option.href} {...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${ - option.primary - ? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]" - : "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]" - }`} + className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]" >

@@ -237,9 +232,10 @@ export function SponsorSection() {

+

Paseo is an independent project used by tens of thousands of developers daily.

- I build Paseo on my own, with no investors and no company behind it, and I have turned - down funding offers to keep it that way. A tool that sits between you and your code, + It is built by one person, full time, with no investors and no company behind it. I have + turned down funding to keep it that way. A tool that sits between you and your code, your keys and your machines has to stay neutral, and funding creates pressure to monetize.

@@ -248,7 +244,7 @@ export function SponsorSection() { Paseo Hub - , and your support is what lets me work on it full time. + . Your support is what lets me keep working on it.

{MAINTAINER_LINK}, maintainer

diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index 0d607daea7..53f3118cc6 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -45,7 +45,7 @@ Both Claude Desktop and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure. @@ -84,7 +84,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" Both tools support parallel coding sessions, including Git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Voice @@ -94,25 +94,31 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel ## Comparison -| | Paseo | Claude Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Not published as open source | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile | -| Coding agents | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code | -| Product account required | No | Claude account | -| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions | -| Required cloud connection | No | Claude account and services | -| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows | -| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services | -| Application plugins | Server code and native client components | No | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Claude Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| License | Open source (Apache-2.0) | Not published as open source | +| Account required | No | Claude account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Claude app (Dispatch and Cowork) | +| CLI | Yes (everything the app does) | Yes (Claude Code CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH host, Anthropic-managed cloud sessions) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Anthropic Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | Yes | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | Yes (file editor) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | PR monitoring and merge workflows | +| In-app browser | Yes (element picker, agent browser tools) | Yes (live app preview) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | Yes (scheduled tasks) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes (in Claude's mobile and app surfaces) | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor). diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index 36a3b6935b..d184dac1cc 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -45,7 +45,7 @@ OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host. @@ -55,7 +55,7 @@ Both tools support Git worktrees for parallel work. Codex App creates Codex-managed worktrees under `$CODEX_HOME/worktrees` and supports local environment setup scripts and project actions through `.codex` configuration. -Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web.fix-auth.my-app.localhost` so parallel services don't fight for ports. +Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web--fix-auth--my-app.localhost` so parallel services don't fight for ports. ## GitHub and review @@ -88,23 +88,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | Codex App | -| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | -| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android | -| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex | -| Product account required | No | OpenAI account | -| Required Paseo cloud connection | No | Not applicable | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR | -| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote | -| Application plugins | Server code and native client components | No | -| Voice | Dictation and realtime voice | Dictation | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Codex App | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | +| Account required | No | OpenAI account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | ChatGPT app (Remote) | +| CLI | Yes (everything the app does) | Yes (Codex CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (connected host, OpenAI-managed cloud) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | OpenAI Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | Commit, push, PR | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | - | See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents). diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index 9c0a30af34..a3455bb0aa 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -67,11 +67,11 @@ Conductor lists its API as a Pro feature but does not document a user-facing CLI Both tools isolate parallel agents in git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Mobile -Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Conductor lists its mobile app as coming soon under the Pro plan. ## Voice @@ -79,21 +79,31 @@ Paseo supports local speech-to-text and text-to-speech. Conductor does not curre ## Comparison -| | Paseo | Conductor | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Closed source | -| Platforms | macOS, Linux, Windows | macOS only | -| Native mobile | iOS, Android | Coming soon under Pro | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | — | -| Split panes and tabs | Yes | — | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | — | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| CLI | Run, `--host`, ls, send, schedule, loop | — | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | Not documented | -| Self-hosted daemon | Yes | — | +| | Paseo | Conductor | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | +| License | Open source (Apache-2.0) | Closed source | +| Account required | No | Yes | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS only) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon under Pro | +| CLI | Yes (everything the app does) | API under Pro | +| Remote machines | Yes (install the daemon anywhere) | No (managed cloud workspaces under Pro) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | No | +| SSH access | Yes | No | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex, Cursor, OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | - | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index b92a3f651e..f0641666c6 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -37,7 +37,7 @@ Paseo's app handles commit, push, opening PRs, watching checks and reviews, and ## Mobile -Both tools ship native iOS and Android apps. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Happy Coder also ships native iOS and Android apps. ## Providers @@ -53,7 +53,7 @@ Happy Coder does not document an application extension API for adding both serve ## Worktrees and services -Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port. +Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for the same port. Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle. @@ -79,21 +79,31 @@ Both products support voice interaction. Paseo can run speech-to-text and text-t ## Comparison -| | Paseo | Happy Coder | -| ---------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop app | macOS, Linux, Windows | macOS | -| Native mobile | iOS, Android | iOS, Android | -| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex | -| Split workspace | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — | -| Managed Git worktrees | Yes | Existing worktree paths | -| Per-worktree dev server URLs | Yes | — | -| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions | -| Application plugins | Server code and native client components | No | -| Voice | Local or configured cloud speech | Yes | +| | Paseo | Happy Coder | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS) | +| Mobile app | Yes (native, full parity with desktop) | Yes (native) | +| CLI | Yes (everything the app does) | Yes (launch and control sessions) | +| Remote machines | Yes (install the daemon anywhere) | - | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (end-to-end encrypted) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Existing worktree paths, no worktree management | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (wraps the agent CLI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | - | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index f71c02ed46..bec7765588 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -51,26 +51,35 @@ OpenChamber's CLI runs and manages its server, remote access, and updates. Its A ## Mobile and voice -Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. +Both tools provide iOS and Android clients and support dictation and spoken replies. The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. ## Comparison -| | Paseo | OpenChamber | -| ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile implementation | React Native | Capacitor WebView | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Workspace views | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Preview and port detection | -| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool | -| Schedules | Yes | Yes | -| Voice | Local dictation and realtime voice | Dictation and spoken replies | -| Self-hosted daemon | Yes | Yes | +| | Paseo | OpenChamber | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (Capacitor WebView) | +| CLI | Yes (everything the app does) | Yes (runs and manages the server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (server on another machine) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees, multi-run comparisons) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (Agent Control Tool, OpenCode agents only) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub (issue, PR, checks, review, merge) | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Preview and port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation and spoken replies | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 5321dd3ae7..5279fb55a2 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -43,7 +43,7 @@ Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenCode Desktop is a desktop app. OpenCode also has web and share-link workflows, but not a native mobile app. @@ -78,7 +78,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" ## Worktrees and services -Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports. +Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for ports. OpenCode supports multi-session work on the same project. If you want worktree isolation around OpenCode sessions, Paseo can provide that by launching OpenCode inside Paseo workspaces. @@ -94,23 +94,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | OpenCode Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | No | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Multi-model support | Through supported agent harnesses | Through OpenCode providers | -| Git worktrees | Yes | No built-in worktree manager | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Desktop sessions | -| In-app terminal | Yes | OpenCode terminal workflow | -| In-app browser | Yes | No | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration | -| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI | -| MCP server for orchestration | Yes | MCP support inside OpenCode | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | No | -| Self-hosted daemon | Yes | OpenCode server / local runtime | +| | Paseo | OpenCode Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (beta, macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | No | +| CLI | Yes (everything the app does) | Yes (OpenCode CLI) | +| Remote machines | Yes (install the daemon anywhere) | OpenCode server | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode (many model providers) | +| Parallel agents | Yes (isolated worktrees, across machines) | Multiple sessions, no built-in worktree manager | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | MCP support inside OpenCode | +| Editor | Yes | - | +| Terminals | Yes | OpenCode terminal workflow | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab integrations | +| In-app browser | Yes (element picker, agent browser tools) | No | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | No | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/orca.md b/packages/website/src/content/alternatives/orca.md index d47ce3c218..4286f3794e 100644 --- a/packages/website/src/content/alternatives/orca.md +++ b/packages/website/src/content/alternatives/orca.md @@ -17,7 +17,7 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop, Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals. -Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client. +Paseo is built around agent chat. The app ships what every developer uses: editor, terminals, diffs, pull requests, and a browser. Features only some teams need, like issue boards or cloud VMs, are plugins you install, so nothing you do not use is in the way. Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source. @@ -31,7 +31,7 @@ Orca runs inside its desktop app by default and reaches other machines in two wa Both tools have iOS and Android apps. -Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. They are on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop. @@ -51,7 +51,7 @@ Paseo runs Claude Code, Codex, OpenCode, Pi, Antigravity, and Muse Code through [Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android. -Community plugins are listed at [paseo.cafe](https://paseo.cafe). +Community plugins are listed at [paseo.sh/plugins](/plugins). Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app. @@ -59,13 +59,13 @@ Orca has an experimental plugin system that is off by default. Orca plugins can Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them. -Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule. +Paseo includes an editor, terminals, diff review with comments that go to the agent, pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg, a browser with an element picker and agent browser tools, plan usage, schedules, and heartbeats. Boards and cloud VMs are what a plugin adds. ## Workspaces and review Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. +Paseo has diff review with comments that go to the agent and opens pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg. It also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. Orca has port forwarding for SSH worktrees and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. ## Automation @@ -89,24 +89,31 @@ Orca does not require an account for local use. Orca Relay and shared artifacts ## Comparison -| | Paseo | Orca | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Funding | Independent | Venture-funded (Y Combinator) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | iOS, Android APK (beta) | -| Main interface | Agent chat | Terminal, with experimental Chat UI | -| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server | -| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Any CLI agent in a terminal | -| Application plugins | Server code and native client components on every client | Experimental, desktop only | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes, with Design Mode | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees | -| Automation | CLI, SDK, MCP | CLI, automations | -| Voice | Local dictation and realtime voice | Dictation | -| Telemetry | None | Anonymous usage data, opt-out | +| | Paseo | Orca | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | No for local use, Orca account for relay | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (companion app, beta) | +| CLI | Yes (everything the app does) | Yes (worktrees, browser, automations) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH worktrees, Remote Orca Server in beta) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (beta, Orca account required) | +| Direct network access | Yes (LAN, Tailscale, VPN) | Yes (LAN, Tailscale) | +| SSH access | Yes | Yes (SSH worktrees) | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Any CLI agent in a terminal, 30+ listed | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (main interface, experimental Chat UI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (agents call the Orca CLI through installed skills) | +| Editor | Yes | Yes (Monaco) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab, Bitbucket, Azure DevOps, Gitea | +| In-app browser | Yes (element picker, agent browser tools) | Yes (Design Mode) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port forwarding for SSH worktrees | +| Schedules and heartbeats | Yes | Yes (scheduled agent runs) | +| Plan usage | Yes | Yes (usage tracking, account switching) | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | Experimental, desktop only | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | Anonymous usage data, opt-out | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 3dc33113ce..30877bc79e 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -51,7 +51,7 @@ Both tools provide Git worktrees, split panes, terminals, diff review, an in-app Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. +Paseo also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. ## Automation @@ -65,23 +65,31 @@ Superset has a free individual tier. Team features, remote access, and integrati ## Comparison -| | Paseo | Superset | -| ---------------------------- | ---------------------------------------- | -------------------------------------- | -| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | -| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux | -| Native mobile | iOS, Android | Coming soon | -| Account required | No | Yes, with GitHub sign-in | -| Agent harnesses | Native, ACP, and custom CLI | CLI agents | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port detection | -| Automation | CLI, SDK, MCP | CLI, SDK, MCP | -| Schedules | Yes | Yes | -| Local voice | Dictation and realtime voice | No documented voice support | -| Self-hosted daemon | Yes | Host server | +| | Paseo | Superset | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | +| Account required | No | Yes, with GitHub sign-in | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, experimental Linux) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon | +| CLI | Yes (everything the app does) | Yes (plus SDK and MCP server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (host server, remote access through Superset cloud on paid plans) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Superset cloud services (paid plans) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | CLI agents, custom terminal agents | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (lifecycle status for supported agents) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (CLI, SDK, and MCP server create workspaces and launch agents) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 38806673f9..39e7f64a7b 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/")({ head: () => pageMeta( "Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere", - "Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.", + "Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.", "/", ), component: Home, @@ -24,9 +24,9 @@ function Home() { } subtitle={ <> - Run any coding agent from anywhere. + Run many coding agents at once, on your machines.
- Self-hosted, multi-provider, open source + Desktop and mobile. Open source. } /> diff --git a/packages/website/src/routes/sponsor.tsx b/packages/website/src/routes/sponsor.tsx index 6c5b1b4f46..83184e6512 100644 --- a/packages/website/src/routes/sponsor.tsx +++ b/packages/website/src/routes/sponsor.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute("/sponsor")({ head: () => pageMeta( "Sponsor Paseo", - "Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", + "Paseo is an independent project used by tens of thousands of developers daily, built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", "/sponsor", ), component: Sponsor, diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 946176fb43..bae421ff22 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -504,6 +504,24 @@ background-color: rgba(255, 255, 255, 0.02); } +/* Comparison tables: the browser would size each column by its longest cell, + so the two product columns get equal width regardless of content. */ +.comparison-prose table { + display: table; + table-layout: fixed; +} + +.comparison-prose th, +.comparison-prose td { + overflow-wrap: anywhere; + hyphens: auto; +} + +.comparison-prose th:first-child, +.comparison-prose td:first-child { + width: max(22%, 7.5rem); +} + .docs-prose pre { background-color: var(--color-card) !important; border: 1px solid var(--color-border); From dc785bb29aa720ad8f82b1d81bd95e61955784b6 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:54:09 +0200 Subject: [PATCH 120/281] Keep both subagent cards labeled when Claude launches two background agents together (#6268) * fix(server): keep a subagent card labeled when two background agents launch together The daemon's stream coalescer kept only the last update per tool call inside its 60 ms window. When Claude launches two background Agent calls in one message, the first child's task_started arrives a few ms after the second call starts streaming, so its labeled card is buffered and then overwritten by the unlabeled completed Agent update. The coalescer now folds updates with the same merge the timeline projection applies, so a labeled detail is not replaced by an unknown one. Closes #6252 * refactor(server): compute the merged tool call before building the coalescer entry --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../agent/agent-stream-coalescer.test.ts | 51 +++++++++++++++++++ .../server/agent/agent-stream-coalescer.ts | 10 +++- .../src/server/agent/timeline-projection.ts | 3 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index c48a5ec8e4..1cdf4bf375 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => { ]); }); + test("keeps a known tool call detail when a later update in the window has none", () => { + const { coalescer, flushes } = createHarness(); + primeLeadingEdge(coalescer, flushes); + + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }), + ); + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { description: "List files" }, output: null }, + }), + ); + + expect(flushes.map((flush) => flush.item)).toEqual([ + { + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }, + ]); + }); + test("preserves mixed text and tool call arrival order within a flush", async () => { const { coalescer, flushes } = createHarness(); primeLeadingEdge(coalescer, flushes); diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index ca52a5479e..c11d968228 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js"; +import { mergeToolCallItems } from "./timeline-projection.js"; export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60; @@ -183,9 +184,16 @@ export class AgentStreamCoalescer { } const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId); + const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined; + // Merge rather than replace, so an update that carries less (an unknown detail after a + // labeled one) cannot erase what the timeline would have kept had both been flushed. + let item = event.item; + if (existing?.kind === "tool_call") { + item = mergeToolCallItems(existing.item, event.item); + } const entry: PendingToolCallEntry = { kind: "tool_call", - item: event.item, + item, provider: event.provider, ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), }; diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index d8128a6a2b..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract, incoming: Extract, ): Extract { From 4dd0e50e78b40cf99739a41c602b3a893c2f59ca Mon Sep 17 00:00:00 2001 From: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:53:18 +0900 Subject: [PATCH 121/281] fix(server): time out stalled encrypted relay handshakes (#5648) --- .../server/src/server/relay-transport.test.ts | 33 ++++++++++++++++++- packages/server/src/server/relay-transport.ts | 7 ++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 06775e5c5c..2dcf29b948 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; @@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); + test("releases a stalled encrypted data connection without restarting control", async () => { + vi.useFakeTimers(); + let attachments = 0; + controllers.push( + startRelayTransport({ + logger: pino({ level: "silent" }), + attachSocket: async () => { + attachments += 1; + }, + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair: generateKeyPair(), + createWebSocket: relay.createWebSocket, + }), + ); + const control = relay.sockets[0]; + control.open(); + const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); + control.message(connected); + const stalled = relay.sockets[1]; + stalled.open(); + await vi.advanceTimersByTimeAsync(15_000); + expect(stalled.terminateCalls).toBe(1); + expect(attachments).toBe(0); + expect(control.terminateCalls).toBe(0); + control.message(connected); + expect(relay.sockets).toHaveLength(3); + expect(relay.sockets[2].url).toBe(stalled.url); + }); + test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index ddf1a3c234..f7b73a5367 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -420,6 +420,10 @@ async function attachEncryptedSocket( attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, metadata?: ExternalSocketMetadata, ): Promise { + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, 15_000); try { const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); @@ -440,6 +444,7 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); + clearTimeout(handshakeTimeout); const encryptedSocket = createEncryptedRelaySocket({ channel, emitter, @@ -459,6 +464,8 @@ async function attachEncryptedSocket( } catch { // ignore } + } finally { + clearTimeout(handshakeTimeout); } } From 8f1091da64f660827f38512cbbd073357445b138 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:55:01 +0200 Subject: [PATCH 122/281] Let a Claude agent leave Auto mode when Claude Code uses Bedrock or Vertex (#6272) * fix(claude): let an agent leave Auto mode when Claude Code uses Bedrock or Vertex Changing the mode of a Claude agent whose process is not running starts the process first. It was started in the agent's current mode, so an agent in Auto on Bedrock or Vertex failed the Auto check before the new mode applied, and every mode change and message failed the same way. Start the process in the requested mode instead. Closes #5878 * fix(claude): relaunch a query whose mode change Claude Code rejected A mode change that starts the query now launches it in the requested mode. If Claude Code then rejects the mode, the agent keeps its previous mode, so mark that query for relaunch instead of reusing it in the rejected mode. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../providers/claude/agent.redesign.test.ts | 55 ++++++++++++++++++- .../server/agent/providers/claude/agent.ts | 21 ++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5069c3653f..747a466c32 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -1037,11 +1088,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index f9f79862ad..ddac56a0eb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2441,8 +2441,15 @@ class ClaudeAgentSession implements AgentSession { const normalized = isPermissionMode(modeId) ? modeId : "default"; assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const activeQuery = await this.ensureQuery(); - await activeQuery.setPermissionMode(normalized); + const launchesQuery = !this.query || this.queryRestartNeeded; + const activeQuery = await this.ensureQuery(normalized); + try { + await activeQuery.setPermissionMode(normalized); + } catch (error) { + // The query was launched in the rejected mode; relaunch in the current mode next time. + if (launchesQuery) this.queryRestartNeeded = true; + throw error; + } if (normalized === "plan") { if (previousMode !== "plan") { this.planResumeMode = previousMode; @@ -3128,7 +3135,7 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } - private async ensureQuery(): Promise { + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { if (this.query && !this.queryRestartNeeded) { return this.query; } @@ -3172,7 +3179,7 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput(); - const options = await this.buildOptions(); + const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.query = claudeQuery( @@ -3298,7 +3305,7 @@ class ClaudeAgentSession implements AgentSession { }); } - private async buildOptions(): Promise { + private async buildOptions(permissionMode: PermissionMode): Promise { const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const providerOptions = applyClaudeToolPolicy( @@ -3307,7 +3314,7 @@ class ClaudeAgentSession implements AgentSession { ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.harnessEnvironment; - assertClaudeModeCanRun(this.currentMode, sdkEnv); + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3330,7 +3337,7 @@ class ClaudeAgentSession implements AgentSession { const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, - permissionMode: this.currentMode, + permissionMode, // Dynamic mode switching can recreate the underlying Claude query. Keep the // bypass launch capability available so later setPermissionMode("bypassPermissions") // calls do not fail after a model/thinking/rewind-driven restart. From a162e1075f2ca83db38e8fd409cbe0bce0440e96 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:15:19 +0200 Subject: [PATCH 123/281] fix(acp): apply configured models the agent does not advertise (#6273) * fix(acp): apply configured models the agent does not advertise A model added through a provider's models or additionalModels was listed in the picker, but an ACP session only accepted ids the agent advertised in session/new, so the agent silently ran on its default model. The registry now passes configured model ids to the session, which applies them through session/set_model. Unconfigured, unadvertised ids are still skipped with a warning. * fix(acp): send configured models when the agent advertises none Also covers the resume path in the regression test. * test(acp): keep configured-model tests in the ACP agent suite --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/provider-registry.ts | 5 +- .../server/agent/providers/acp-agent.test.ts | 152 +++++++++++++++++- .../src/server/agent/providers/acp-agent.ts | 54 ++++--- 4 files changed, 194 insertions(+), 24 deletions(-) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3e5e739577..34d36c9e16 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions { * Defaults to true. Providers that cannot honor false should no-op. */ persistSession?: boolean; + /** + * Model ids added by provider configuration (`models` / `additionalModels`). + * Providers that validate against runtime-advertised models must accept these. + */ + configuredModelIds?: readonly string[]; } /** What a resumed session is for: driving the agent, or reading what it already did. */ @@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history"; export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ purpose?: AgentResumePurpose; + /** See AgentCreateSessionOptions.configuredModelIds. */ + configuredModelIds?: readonly string[]; } /** diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index bd22d37df3..2698a3b2ac 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -492,6 +492,7 @@ function wrapClientProvider( const listFeatures = inner.listFeatures?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, @@ -509,7 +510,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -526,7 +527,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 95d7e25a21..33c9696900 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -5,7 +5,7 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, ClientSideConnection, @@ -54,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentPersistenceHandle, + AgentSession, ProviderRefreshContext, } from "../agent-sdk-types.js"; +import { buildProviderRegistry } from "../provider-registry.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -4370,3 +4372,151 @@ describe("ACPAgentSession close() with an unresponsive provider", () => { expect(isProcessAlive(pid)).toBe(false); }, 15_000); }); + +// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but, +// like Cline, runs any model id it is given. +const PERMISSIVE_MODEL_ACP_AGENT = ` +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +let currentModelId = "advertised-default"; +const modelState = () => + process.env.ACP_ADVERTISE_MODELS === "none" + ? undefined + : { + currentModelId, + availableModels: [ + { modelId: "advertised-default", name: "Advertised default" }, + { modelId: "advertised-other", name: "Advertised other" }, + ], + }; +new AgentSideConnection( + () => ({ + async initialize() { + return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; + }, + async newSession() { + return { sessionId: "permissive-session", models: modelState() }; + }, + async loadSession() { + return { models: modelState() }; + }, + async unstable_setSessionModel({ modelId }) { + currentModelId = modelId; + return {}; + }, + async authenticate() {}, + async cancel() {}, + async prompt() { + return { stopReason: "end_turn" }; + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +describe("custom ACP provider with configured models", () => { + let dir: string; + let agentScript: string; + let session: AgentSession | null = null; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-")); + agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + }); + + afterEach(async () => { + await session?.close(); + session = null; + await rm(dir, { recursive: true, force: true }); + }); + + function buildRegistry( + override: { + models?: Array<{ id: string; label: string }>; + additionalModels?: Array<{ id: string; label: string }>; + }, + agentEnv: Record = {}, + ) { + return buildProviderRegistry(createTestLogger(), { + providerOverrides: { + permissive: { + extends: "acp", + label: "Permissive", + command: [process.execPath, agentScript], + env: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ...agentEnv, + }, + ...override, + }, + }, + }); + } + + async function createSessionOnModel( + registry: ReturnType, + model: string, + ): Promise { + const client = registry.permissive.createClient(createTestLogger()); + session = await client.createSession({ provider: "permissive", cwd: dir, model }); + return session; + } + + test("runs on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from a replacement models list that the agent does not advertise", async () => { + const registry = buildRegistry({ + models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from additionalModels when the agent advertises no models", async () => { + const registry = buildRegistry( + { additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] }, + { ACP_ADVERTISE_MODELS: "none" }, + ); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("resumes on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + const client = registry.permissive.createClient(createTestLogger()); + + session = await client.resumeSession( + { provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } }, + { model: "stealth/pixel-canary" }, + ); + + expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("keeps the agent's model when the requested model is neither advertised nor configured", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stale/removed-model"); + + expect((await created.getRuntimeInfo()).model).toBe("advertised-default"); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index e5d1e4c48e..6bded49feb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -77,6 +77,7 @@ import { type AgentPromptContentBlock, type AgentPromptInput, type AgentResumePurpose, + type AgentCreateSessionOptions, type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, @@ -512,6 +513,7 @@ interface ACPAgentSessionOptions { extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record; waitForInitialCommands?: boolean; @@ -979,6 +981,7 @@ export class ACPAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { this.assertProvider(config); const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); @@ -1006,6 +1009,7 @@ export class ACPAgentClient implements AgentClient { supportsMcpServers: providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1064,6 +1068,7 @@ export class ACPAgentClient implements AgentClient { }, handle, resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1692,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1760,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -2194,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -2213,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -2922,13 +2936,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { const configuredModelId = this.config.model; let switchedModel = false; if (configuredModelId && configuredModelId !== this.currentModel) { - const selection = resolveACPModelSelection({ - modelId: configuredModelId, - availableModels: this.availableModels, - configOptions: this.configOptions, - }); try { - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + await this.setModelWithSelection({ + modelId: configuredModelId, + selection: this.resolveModelSelection(configuredModelId), + }); switchedModel = true; } catch (error) { if (!this.isModelSelectionUnavailableError(error)) { From 1e00f452ea8ec74254860a977cdeeb5f14dc3fad Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:59:53 +0200 Subject: [PATCH 124/281] Download files whose names have non-ASCII characters (#6278) * fix(server): encode Unicode download filenames in Content-Disposition * test(server): cover Korean download filenames * test(server): validate download token and agent ID formats * fix(server): set the download header through Express instead of a new dependency The download route already runs on an Express response, and res.attachment() builds Content-Disposition with the same content-disposition encoder that Express ships. Use it and drop the direct content-disposition dependency and its type package. The header values the download tests assert are unchanged. Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com> --------- Co-authored-by: Dillon Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com> --- packages/server/src/server/bootstrap.ts | 3 +- .../daemon-e2e/file-download.e2e.test.ts | 89 +++++++++++++------ 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3f49f41a19..5c1d0d1404 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -836,9 +836,8 @@ export async function createPaseoDaemon( return; } - const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); + res.attachment(entry.fileName); res.setHeader("Content-Type", entry.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index f9d7fce7b4..685d056a27 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -24,42 +24,73 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(agent.id).toBeTruthy(); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); - const body = await response.text(); - expect(body).toBe(fileContents); - - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( From 2323ee68445c00afc737e021c130bcf0c0da461d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:51:12 +0200 Subject: [PATCH 125/281] Keep reporting a Linux daemon as running after its VM resumes from a host sleep (#6279) * Keep reporting a Linux daemon as running after its VM resumes from a host sleep A VM paused while its host sleeps keeps its uptime, but its wall clock catches up on resume. The boot instant derived from the two then lands after the running supervisor's lock, so daemon status reported it as stopped and daemon stop released its lock without stopping it. The lock now records Linux's boot id, and a lock that carries one is judged by it. Locks without one keep the wall-clock check. Closes #6019 * Give the OMP idle-deadline test a deadline a slow runner cannot pass before the first wait With a 1 ms deadline, the gate's first check, made synchronously when the turn ends, could already be past it on a loaded Windows runner. The turn then failed before the test's first scheduler wait, and the test timed out. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/omp/agent.test.ts | 6 ++- .../server/src/server/daemon-instance.test.ts | 41 ++++++++++++++++++- packages/server/src/server/pid-lock.ts | 30 +++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 537c94f2a6..c0e6193bb5 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -459,7 +459,9 @@ describe("OMP agent client and session", () => { test("fails a turn when the provider idle gate passes its deadline", async () => { const scheduler = new ManualIdleScheduler(); - const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 }); + // Long enough that the gate's first check, made right after the turn ends, cannot already + // be past it on a slow runner. + const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 }); await omp.start(); const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", { isStreaming: true, @@ -473,7 +475,7 @@ describe("OMP agent client and session", () => { args: { command: "sleep 30" }, }); expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]); - await new Promise((resolve) => setTimeout(resolve, 2)); + await new Promise((resolve) => setTimeout(resolve, 60)); scheduler.retry(); await expect(completion).rejects.toThrow(/provider idle/i); expect(omp.runningToolCallIds()).toEqual([]); diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts index 9d7ea66c5a..ee428b2e58 100644 --- a/packages/server/src/server/daemon-instance.test.ts +++ b/packages/server/src/server/daemon-instance.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, uptime } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; @@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => { await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); }); }); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index db95d4786d..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; @@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({ serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer {} @@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean { return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; } +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + /** * Whether the process that wrote this lock is still running. * * A PID alone does not identify the supervisor: the operating system hands the number to * something else once the supervisor is gone, and a reboot reassigns it freely. A process - * cannot predate the boot it runs under, so a lock stamped before this boot is abandoned + * cannot predate the boot it runs under, so a lock written during another boot is abandoned * however alive its PID looks. */ export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { - if (precedesThisBoot(lock.startedAt)) return false; + if (!writtenDuringThisBoot(lock)) return false; return isPidRunning(lock.pid); } @@ -214,6 +238,7 @@ export async function acquirePidLock( } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), @@ -221,6 +246,7 @@ export async function acquirePidLock( uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; From f13bb2136cc67265c5bb8c61020caf4c2f606f75 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:57:29 +0200 Subject: [PATCH 126/281] fix(server): keep accented names on downloaded files (#6281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file name that fits in Latin-1, like café.txt, went out as a raw byte in Content-Disposition filename= with no filename* parameter, and Chromium saved the file under a name taken from the URL (download.txt). Build the header so every non-ASCII name carries filename*=UTF-8''… with an ASCII fallback. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/src/server/bootstrap.ts | 3 +- .../daemon-e2e/file-download.e2e.test.ts | 4 ++ .../file-download/content-disposition.test.ts | 42 +++++++++++++++++++ .../file-download/content-disposition.ts | 32 ++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/file-download/content-disposition.test.ts create mode 100644 packages/server/src/server/file-download/content-disposition.ts diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5c1d0d1404..0ec792e871 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -836,8 +837,8 @@ export async function createPaseoDaemon( return; } - res.attachment(entry.fileName); res.setHeader("Content-Type", entry.mimeType); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index 685d056a27..ac138a8c01 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -26,6 +26,10 @@ describe("daemon E2E", () => { describe("file download tokens", () => { test.each([ { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, { fileName: "中文报告 (最终版).txt", disposition: diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} From 28f7528588bc4ea74d4b9394560191bc16c20b84 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:51:17 +0200 Subject: [PATCH 127/281] docs: fix the Tailscale Serve command and log rotation defaults (#6284) The web UI guide gave the pre-1.52 `tailscale serve https / ` form, which current Tailscale rejects with "invalid number of arguments". The configuration page said logs keep 2 files in total, while the daemon keeps 3 rotated files next to the active one. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- public-docs/configuration.md | 4 ++-- public-docs/web-ui.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public-docs/configuration.md b/public-docs/configuration.md index ca578a96ac..a11c4c69ba 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -141,7 +141,7 @@ Daemon logging uses separate console and file sinks by default: - Console: `info` and above - File (`$PASEO_HOME/daemon.log`): `trace` and above -- File rotation: `10m` max file size, `2` retained files total (active + 1 rotated) +- File rotation: `10m` max file size, `3` rotated files kept alongside the active file ```json { @@ -155,7 +155,7 @@ Daemon logging uses separate console and file sinks by default: "path": "daemon.log", "rotate": { "maxSize": "10m", - "maxFiles": 2 + "maxFiles": 3 } } } diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index 9e80e8355e..6fe574e3cd 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -197,7 +197,7 @@ If you don't want to manage a reverse proxy or open ports, a tunnel gives you an - **Tailscale Serve** keeps it inside your tailnet, no public exposure, TLS handled for you: ```bash - tailscale serve https / http://127.0.0.1:6767 + tailscale serve --bg http://127.0.0.1:6767 ``` Reach it at `https://..ts.net/`. Only devices on your tailnet can connect. From 56f4fca9e4a0f54162aac0f8e35d3cf126c23246 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 11:40:18 +0100 Subject: [PATCH 128/281] docs(workspaces): guide parallel development and review (#6294) --- README.md | 2 ++ public-docs/index.md | 1 + public-docs/orchestration.md | 2 ++ public-docs/parallel-development.md | 52 +++++++++++++++++++++++++++++ public-docs/providers.md | 4 ++- public-docs/workspaces.md | 2 ++ 6 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 public-docs/parallel-development.md diff --git a/README.md b/README.md index a8fe3f5b25..4b769466c0 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. +[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. + ## Plugins Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, diff --git a/public-docs/index.md b/public-docs/index.md index f93216883d..5aae08185f 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -71,6 +71,7 @@ The image runs the daemon and serves the bundled web UI. It does not bundle agen ## Where next +- [Run parallel tasks](/docs/parallel-development), use separate worktrees, review diffs, and test changes in the desktop app. - [Connectivity](/docs/connectivity), connect through the relay or Tailscale. - [Docker](/docs/docker), run the daemon and bundled web UI in a container. - [Workspaces](/docs/workspaces), the project, workspace, and session model Paseo is built around. diff --git a/public-docs/orchestration.md b/public-docs/orchestration.md index 74c15a36aa..b7c5b861f2 100644 --- a/public-docs/orchestration.md +++ b/public-docs/orchestration.md @@ -10,6 +10,8 @@ category: Orchestration Paseo lets your coding agents coordinate other agents, split work across providers and machines, and keep tasks moving automatically. +You can also start and supervise each task yourself in the desktop app. [Run parallel tasks in separate worktrees](/docs/parallel-development), inspect their diffs, and test each app in a terminal and browser before choosing what to merge. + ## What your agents can do - **Choose providers and models:** launch other agents using any provider and model configured on the host. diff --git a/public-docs/parallel-development.md b/public-docs/parallel-development.md new file mode 100644 index 0000000000..4d9c450d25 --- /dev/null +++ b/public-docs/parallel-development.md @@ -0,0 +1,52 @@ +--- +title: Run parallel tasks +description: Run coding agents in separate worktrees, review their diffs, and test each change with terminals and the built-in browser in Paseo. +nav: Run parallel tasks +order: 12 +category: Workspaces +--- + +# Run parallel tasks + +Use Paseo's desktop app to give several coding agents independent tasks, review their changes, and run the results. Each task gets a workspace with its own git worktree and branch, with agents, files, diffs, terminals, and browser tabs together. + +Start with the [desktop app](/docs#desktop-app-recommended), a local git repository, and at least one [coding agent installed and authenticated](/docs/supported-providers). The examples below use a repository with an `origin/main` branch. + +## Start two tasks + +1. Open **New workspace** and select your project. +2. Set **Isolation** to **New worktree** and choose `origin/main` as the starting ref. +3. Choose an agent and model, then submit a task, for example: “Add filtering to the project list. Run the relevant tests.” +4. Create a second workspace with **New worktree** from the same starting ref. Give its agent another task: “Add keyboard navigation to the settings page. Run the relevant tests.” + +Both agents run concurrently. Select either workspace in the sidebar to read progress or send a follow-up. Choose the same provider for both tasks or a different provider for each. + +Separate worktrees keep their file edits on separate branches. For two alternative implementations of the same feature, give both agents the same task and compare their results before choosing one. + +## Review each change + +Select a workspace and open **Changes** from the Command Center to inspect its diff. Open **Files** to browse and edit the source. Use split panes to keep the agent conversation beside the code you're reviewing. + +Send corrections to that workspace's agent. To get another opinion, use **New agent** in the same workspace and ask it to review the changes without editing files. Agents in the same workspace share its working directory. + +## Run and test the app + +In the selected workspace, use **New terminal** from the Command Center. Install dependencies and run your project's test or development command there. + +Each worktree has its own files, but processes still share the machine's network ports. Assign a different dev-server port to each running copy. For repeatable setup and service ports, configure [worktree setup and scripts](/docs/worktrees#paseojson). + +For a web app, use **New browser** and enter that workspace's dev-server URL. Exercise the changed flow, then switch workspaces to test the other implementation. Browser tabs run inside the desktop app. + +You can also ask the agent to check the app in those tabs. Enable [browser automation](/docs/browser#enabling), then give it the URL and concrete steps to verify, for example: + +> Open http://localhost:3001, filter the project list by name, and verify that clearing the filter restores the full list. Report any failures and take a screenshot of the result. + +## Choose what to merge + +Review the diff and test results for each workspace. Ask for any remaining changes before committing and opening a pull request for that branch, using your usual Git workflow in the workspace terminal or through its agent. + +For independent tasks, review and merge each branch. For competing implementations, choose the version you want to keep. Archive a workspace when you no longer need it; [worktree cleanup](/docs/worktrees#manage-the-workspace) removes the managed directory after its last workspace is archived. + +## Let an agent coordinate the tasks + +When you want an agent to create the workspaces, launch workers, and collect their results, use [orchestration](/docs/orchestration). The workers appear in Paseo so you can inspect their conversations and changes as they work. diff --git a/public-docs/providers.md b/public-docs/providers.md index 6de304a019..fc23bff784 100644 --- a/public-docs/providers.md +++ b/public-docs/providers.md @@ -8,7 +8,9 @@ category: Providers # Providers -Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top. +Paseo runs **existing coding agents you've installed and authenticated** in workspaces with an editor, terminals, diffs, and, on desktop, a browser. Run several agents in parallel, choose a provider for each task, and review their work in the same app. Your subscriptions, config, skills, and MCP servers stay intact. + +To try the workflow, [run parallel tasks in separate worktrees](/docs/parallel-development). ## Mental model diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index caf59884c4..2e74935d54 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -48,6 +48,8 @@ The workspace is the product concept; a git worktree is one way to isolate its f ## Creating a workspace +For a desktop walkthrough from starting parallel agents to reviewing and testing their changes, see [Run parallel tasks](/docs/parallel-development). + You can create a workspace in the app or from the CLI: ```bash From c90587e5b757b8906a995c941ed390f2a5c0d266 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:49:37 +0200 Subject: [PATCH 129/281] Keep a plugin search term typed while the website page is still loading (#6293) * test(website): reproduce plugin search dropping a term typed before hydration Typing in the plugin search box before the page's scripts run leaves the term in the box but not in React state, so Enter searches without it. * fix(website): keep a plugin search term typed before the page loads Text typed into the plugin search box before hydration stayed in the box but not in component state, so Enter searched without it and the clear button never appeared. Adopt the box's value once the page hydrates. Closes #6286 * test(website): cover live browse search typed before the page loads --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/website/e2e/plugins.spec.ts | 43 +++++++++++++++++++ .../website/src/plugins/plugin-search.tsx | 6 +++ 2 files changed, 49 insertions(+) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 1efc20115c..2793792845 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -113,6 +113,35 @@ test("keeps the directory's ranking window when searching", async ({ page }) => ); }); +test("searches for a term typed before the page finished loading", async ({ page }) => { + const loadScripts = await holdScripts(page); + await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" }); + await searchPlugins(page, "graphite"); + await loadScripts(); + await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); +}); + +test("filters browse results for a term typed before the page finished loading", async ({ + page, +}) => { + await page.goto("/"); + const loadScripts = await holdScripts(page); + await page.goto("/plugins/all", { waitUntil: "domcontentloaded" }); + const entries = await historyLength(page); + await searchPlugins(page, "gra"); + await loadScripts(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + test("clears the search with the clear button", async ({ page }) => { await page.goto("/plugins/all"); const searchbox = page.getByRole("searchbox", { name: "Search plugins" }); @@ -304,6 +333,20 @@ test.describe("search engine visits without JavaScript", () => { }); }); +/** Holds the page's scripts so typing lands before hydration; the returned function loads them. */ +async function holdScripts(page: Page): Promise<() => Promise> { + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + await page.route(/\.js($|\?)/, async (route) => { + await held; + await route.continue(); + }); + return async () => { + release(); + await page.waitForLoadState("load"); + }; +} + async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index fdd3a4b5f5..ab648bbd58 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -46,6 +46,12 @@ export function PluginSearch({ }, [live, show, term], ); + // A term typed before the page hydrated is in the box but not in state; adopt it. Once hydrated, + // the box always matches state, so this does nothing. + useEffect(() => { + const typed = input.current?.value ?? ""; + if (typed !== term) edit(typed); + }, [edit, term]); const handleChange = useCallback( (event: ChangeEvent) => edit(event.target.value), [edit], From 8ddeb79c8dbb8934244b35ba18472fb9074d8209 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:08:48 +0200 Subject: [PATCH 130/281] Claude agents stay idle while background helpers work, and send or Stop no longer kills them (#6295) * fix(claude): open autonomous turns only when Claude starts a turn Paseo guessed from frame shape whether the main session had started work on its own, and only a result could close the turn that guess opened. A background subagent's task frames, its own background jobs' task_notification and rate limit events all arrive with no parent tool use id, so they looked like main-session work and opened a turn no result would ever close. The agent then reported "running" while its main session sat idle, and the app's message queue never drained. Claude brackets every main-session turn itself: system/init opens it and exactly one result closes it, whether a prompt, a wake-up after background work, a slash command or an API error started it. Background subagents never emit either on the main stream. An autonomous turn now opens only on that init, and only when no foreground turn is open. The frame heuristic is removed. - Paseo launches the CLI with CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1, added as an overlay in buildSdkEnv so the SDK and the usage probe share one env object. An "idle" session state closes an autonomous turn as a safety net, never a foreground one. "running" opens nothing: Claude Code 2.1.280 stays "running" for as long as any background subagent is alive. State frames never reach the timeline. - An init clears a leftover pendingInterruptAbort. A new turn has begun, so the interrupted one is over, and a stale flag would swallow the new turn's result. Tests that opened autonomous turns from bare frames now send the init Claude sends. New coverage: helper frames never open a turn while the main session is idle, a wake-up turn opens on init and closes on its result, a folded steer keeps one turn while a turn Claude starts after the result gets its own, an idle closes a turn no result closed, session state never opens a turn, and the env flag reaches the SDK launch. docs/agent-lifecycle.md records that only system/init and result bracket a main-session turn, since the code cannot tell a reader why frame shape fails. Co-Authored-By: Claude Opus 5.5 * fix(claude): interrupt Claude only while its main turn runs, and spare helpers Every Stop, and every send that replaces a running turn (paseo send, MCP send_agent_prompt, voice, the app's queue drain, Send now), ends in query.interrupt(). Without the SDK's perTaskStopAffordance declaration that call kills every running background subagent, even when the main session is idle and there is nothing of ours to stop. - Declare perTaskStopAffordance, so an interrupt aborts only the main turn, like Esc in Claude Code. Claude stops a helper itself with its TaskStop tool. - Track whether a main-session turn is in flight from Claude's own brackets: set by system/init and by a pushed message's command_lifecycle "started" (/compact works for seconds before its init), cleared by the result or an idle session state. - Call query.interrupt() and set pendingInterruptAbort only when that turn is in flight, or the cancelled turn's own message has already been started. Otherwise withdraw our not-yet-started messages with cancel_async_message instead: an idle interrupt reaches only helpers, and with no result to follow it the flag would linger. - Track the foreground prompt with the queued steers until Claude starts it, so Stop withdraws a message still waiting in the queue. A turn stopped while the Claude process is still starting never sends it. - Accept command_lifecycle "cancelled" as terminal, so a cancelled steer no longer waits to be withdrawn or keeps denying permission cards. Interrupt tests now wait for the pump to read the init before stopping, since that is when Paseo learns a turn is in flight. New coverage: a stop before Claude starts the message withdraws it without interrupting, a stop while only helpers run never reaches Claude, a stop mid-turn interrupts once and drops the aborted result, a new turn clears a flag no result will consume, the SDK option is passed, and a cancelled steer is not withdrawn again. docs/providers.md described the old unconditional interrupt; it now states when the Claude adapter interrupts and why it declares the affordance. Co-Authored-By: Claude Opus 5.5 * fix(claude): keep a helper spawned in the background out of a turn's cancel When a turn is canceled, Paseo terminalizes the subagents that were running in its foreground and skips the backgrounded ones, which outlive it. It only learned a child was backgrounded from task_updated's patch.is_backgrounded, which Claude sends when a foreground task moves to the background later. A child spawned in the background carries the flag on task_started instead, and a resumed subagent always does, so the track marked those helpers "canceled" while they kept working. Read is_backgrounded from task_started as well. docs/agent-lifecycle.md said task_updated was the only signal; it now names both. Co-Authored-By: Claude Opus 5.5 * fix(app): drain queued messages as steers The composer queue drains when an agent goes idle. It sent each message with no activeTurnBehavior, which the daemon treats as "interrupt": if the agent had just started a turn of its own (a Claude wake-up after background work finished), the drained message stopped that turn and replaced it. Send drained messages with activeTurnBehavior "steer". A drain that races such a turn folds into it; with no turn running the daemon starts a normal one, as before. "steer" is an existing protocol value, so older daemons need nothing new. Co-Authored-By: Claude Opus 5.5 * test(claude): cover background helpers against real Claude Every other test in this file tells Claude "Do not use a background task", so a message sent or a Stop pressed while a backgrounded subagent runs was never exercised. Four real-Claude scenarios cover it: - "a background subagent keeps running after a message is sent while it works": one helper, the main turn ends, the parent is not reported running, a message in that window is answered, and the helper is never read as canceled and completes. - "a helper's own nested background job does not phantom-run the parent or kill the helper": a helper starts its own background Bash job, then keeps working in the foreground. The parent stays idle across the nested job's completion (sampled for 30 s around a 5 s job), a message sent in that window is answered, and the helper reports CHILD_DONE. - "the app's default steer survives a backgrounded helper while the main session works": the app's default send behavior (steer) does not kill a helper when it interrupts the main session's own turn. - "Stop cancels the main session's own turn but spares its background helper": the same for Stop. The wake-up turn Claude starts after a helper finishes lands a moment after the helper reads completed (0.1 to 5 s in recorded traces), and the daemon announces it as a pushed "running" agent_update, not as a turn_started stream event. The checks poll for it and read the final idle once the wake-up settles. waitForFinish can return a stale "idle" right after a send, and the push stream can miss a message's final text when it resolves, so the tests read fetchAgentTimeline like the rest of the file. Helper status comes from agent.provider_subagents.list, since agent_stream carries no provider_subagent events. The scenarios need the previous commits together. Run against the frame heuristic this branch removes, the nested-job test fails: the parent flips to running once the nested job finishes and stays there. Co-Authored-By: Claude Opus 5.5 * fix(claude): interrupt a stopped prompt Claude has already taken Stop before Claude announces a prompt started withdraws it with cancel_async_message instead of interrupting. Claude can dequeue a message for its next turn before announcing it, and withdrawing it is then a no-op that reports cancelled: false, so the stopped prompt kept running. Interrupt whenever a message could not be withdrawn. Co-authored-by: Isak Persson * fix(claude): withdraw a stopped prompt only once When the withdrawal of a stopped prompt failed, the interrupt path withdrew a second time, which could cancel a prompt sent while the first withdrawal was in flight. Withdraw once and interrupt on failure. The real-Claude steer scenario now waits for the background helper to exist before steering, so it proves the helper survives the steer. Co-authored-by: Isak Persson --------- Co-authored-by: Isak Persson Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/agent-lifecycle.md | 3 +- docs/providers.md | 2 +- packages/app/src/runtime/host-runtime.test.ts | 28 + packages/app/src/runtime/host-runtime.ts | 5 + .../agent/providers/claude/agent.env.test.ts | 6 + ...agent.interrupt-restart-regression.test.ts | 80 +- .../providers/claude/agent.redesign.test.ts | 465 +++++++++++ .../claude/agent.subagent-interrupt.test.ts | 28 +- .../agent/providers/claude/agent.test.ts | 11 +- .../server/agent/providers/claude/agent.ts | 179 +++-- .../claude/subagents/live-source.test.ts | 27 + .../providers/claude/subagents/live-source.ts | 28 +- ...d-during-tool-call-claude.real.e2e.test.ts | 721 ++++++++++++++++++ 13 files changed, 1504 insertions(+), 79 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 8662ee3287..d0d057949f 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -197,10 +197,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta - **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent. - **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. -- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. +- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`. - **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. +- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one. - **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent. - **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work. - **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. diff --git a/docs/providers.md b/docs/providers.md index c5f37d4161..4609246922 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -103,7 +103,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback. -A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4. +A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4. `SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text. diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0954b0e422..996a47d498 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -3047,6 +3047,34 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => { + const host = makeHost({ serverId: "srv_steer_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_steer_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer"); + sessionStore.clearSession(host.serverId); + }); + it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); const fakeClient = new FakeDaemonClient(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index de4b40f883..2ecbe60448 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2426,6 +2426,11 @@ export class HostRuntimeStore { }), encodeImages, submission: createMessageSubmissionWriter(serverId), + // The queue drains when the agent goes idle, but the agent can start a turn of its own + // in that same moment (a wake-up after background work). Steering folds the message + // into that turn instead of interrupting it; with no turn running, the daemon starts + // a normal one. + activeTurnBehavior: "steer", }); }, }) diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index c99687e438..0d2748fd62 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -31,6 +31,7 @@ function createQueryMock(events: unknown[]): Query { describe("Claude SDK env", () => { test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record | undefined; + let capturedPerTaskStopAffordance: boolean | undefined; const launchContext: AgentLaunchContext = { env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201", @@ -39,6 +40,7 @@ describe("Claude SDK env", () => { }; const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedEnv = options.env; + capturedPerTaskStopAffordance = options.perTaskStopAffordance; return createQueryMock([ { type: "system", @@ -91,6 +93,10 @@ describe("Claude SDK env", () => { expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout"); expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout"); expect(session.usageSession?.()?.env).toBe(capturedEnv); + // Paseo reads session_state_changed to know when an autonomous turn is over. + expect(capturedEnv?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1"); + // Without it, Stop and replace kill every background subagent along with the turn. + expect(capturedPerTaskStopAffordance).toBe(true); } finally { await session.close(); expect(session.usageSession?.()).toBeNull(); diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index dada35945c..02a09ba44b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -91,6 +91,17 @@ function buildUsage() { }; } +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function buildTurnInit(sessionId: string) { + return { + type: "system", + subtype: "init", + session_id: sessionId, + permissionMode: "default", + model: "opus", + }; +} + function buildSuccessResult(sessionId: string) { return { type: "result", @@ -153,13 +164,7 @@ function createScriptedQuery(params: { }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: params.sessionId, - permissionMode: "default", - model: "opus", - }); + scriptedQuery.emit(buildTurnInit(params.sessionId)); void (async () => { for await (const prompt of params.prompt) { @@ -252,6 +257,21 @@ async function waitFor( } } +/** + * Paseo interrupts Claude only while a main-session turn is in flight, which it learns from the + * init. The pump asks for its next frame only after routing the last one, so a second next() call + * proves the init was read. + */ +async function waitForInitRouted( + query: () => { next: ReturnType } | null | undefined, +) { + await waitFor(() => (query()?.next.mock.calls.length ?? 0) >= 2); +} + +function buildCommandLifecycle(commandUuid: string | null | undefined, state: string) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + afterEach(() => { queryFactory.mockReset(); }); @@ -282,6 +302,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); @@ -322,6 +343,7 @@ async function startSteeredTurn(sessionId: string): Promise<{ throw new Error("Expected the original Claude turn to start"); } await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); const steered = await session.steerActiveTurn!("queued steer", { expectedTurnId: start.value.turnId, @@ -371,11 +393,8 @@ test("interrupt still stops the turn when Claude has already dequeued the steer" test("a steer Claude has already read is no longer discardable on interrupt", async () => { const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session"); - query()?.emit({ - type: "command_lifecycle", - command_uuid: query()?.prompts[1]?.uuid, - state: "completed", - }); + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "completed")); // Frames are translated in order, so the marker landing proves the lifecycle frame was read. query()?.emit({ type: "assistant", message: { content: "STEER_READ" } }); await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ")); @@ -388,6 +407,23 @@ test("a steer Claude has already read is no longer discardable on interrupt", as await session.close(); }); +test("a steer Claude cancelled is no longer withdrawn on interrupt", async () => { + const { session, query, turn } = await startSteeredTurn("queued-steer-cancelled-session"); + + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + // "cancelled" is terminal: API errors, interrupts and "now" pre-emption all end a message. + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "cancelled")); + query()?.emit({ type: "assistant", message: { content: "STEER_CANCELLED" } }); + await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_CANCELLED")); + + await session.interrupt(); + await waitFor(() => query()?.interrupt.mock.calls.length === 1); + + expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled(); + await collectUntilTerminal(turn); + await session.close(); +}); + function buildStoppedTaskNotification(sessionId: string) { return { type: "system", @@ -473,6 +509,7 @@ async function startInterruptedToolTurn(sessionId: string): Promise<{ const turn = streamSession(session, "run the slow tool"); await turn.next(); await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); await session.interrupt(); await collectUntilTerminal(turn); @@ -523,6 +560,7 @@ test("Claude can still wake into an autonomous turn once the interrupted request query()?.emit(buildStoppedTaskNotification(sessionId)); query()?.emit(buildAbortedResult(sessionId)); + query()?.emit(buildTurnInit(sessionId)); query()?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -579,6 +617,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -652,6 +691,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const output = createAsyncQueue>(); const prompts: PromptRecord[] = []; let throwAbortOnNext = false; + let queryRef: Pick | null = null; queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { const scriptedQuery = { @@ -685,13 +725,8 @@ test("recovers when the query pump sees a single interrupt abort before the next }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: "interrupt-abort-recovery-session", - permissionMode: "default", - model: "opus", - }); + queryRef = scriptedQuery; + scriptedQuery.emit(buildTurnInit("interrupt-abort-recovery-session")); void (async () => { for await (const promptMessage of prompt) { @@ -730,6 +765,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); + await waitForInitRouted(() => queryRef); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -768,6 +804,7 @@ test("stale abort result after replacement start does not poison the new foregro const firstTurn = streamSession(session, "first prompt"); const firstStarted = await firstTurn.next(); await waitFor(() => queryRef?.prompts.length === 1); + await waitForInitRouted(() => queryRef); await session.interrupt(); const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; @@ -812,7 +849,7 @@ test("stale abort result after replacement start does not poison the new foregro await session.close(); }); -test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { +test("creates an autonomous live turn when Claude starts a turn without a foreground run", async () => { const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; @@ -848,6 +885,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-live-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -916,6 +954,7 @@ test("steers an autonomous turn through its existing query without restarting it await collectUntilTerminal(streamSession(session, "seed prompt")); const autonomousEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-steer-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_RESPONSE" }, @@ -999,6 +1038,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-handoff-session")); queryRef?.emit({ type: "assistant", message: { content: "BACKGROUND_ONLY_RESPONSE" }, diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 747a466c32..5393926520 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -486,6 +486,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; + mainTurnInFlight: boolean; interruptActiveTurn: () => Promise; } = asInternals(session); const interrupt = vi.fn(async () => undefined); @@ -498,6 +499,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a }; internal.input = { end }; internal.queryRestartNeeded = false; + internal.mainTurnInFlight = true; try { await internal.interruptActiveTurn(); @@ -1191,6 +1193,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); internal.turnState = "idle"; + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump( { type: "stream_event", @@ -1233,6 +1236,468 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou await session.close(); }); +interface SessionStateInternals { + turnState: "idle" | "foreground" | "autonomous"; + routeSdkMessageFromPump: ( + message: Record, + activeQuery: QueryMock, + ) => Promise; + autonomousTurn: { id: string } | null; + activeForegroundTurnId: string | null; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { + type: "system", + subtype: "session_state_changed", + state, + uuid: `state-${state}`, + session_id: "sess-1", + }; +} + +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function claudeTurnInit() { + return { + type: "system", + subtype: "init", + session_id: "sess-1", + permissionMode: "default", + model: "opus", + }; +} + +function successResult() { + return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }; +} + +function commandLifecycle( + commandUuid: string, + state: "queued" | "started" | "completed" | "cancelled", +) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + +function unboundTextDelta(text: string) { + return { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }; +} + +// What a background subagent emits on the main stream with no parent tool use id, so by frame +// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn +// Claude opened, and no result follows any of them. +const helperTaskStarted = { + type: "system", + subtype: "task_started", + task_id: "helper-task", + tool_use_id: "toolu_helper", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Background helper", + is_backgrounded: true, + uuid: "uuid-task-started", + session_id: "sess-1", +}; +const helperTaskProgress = { + type: "system", + subtype: "task_progress", + task_id: "helper-task", + tool_use_id: "toolu_helper", + description: "Background helper", + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 }, + last_tool_name: "Bash", + uuid: "uuid-task-progress", + session_id: "sess-1", +}; +// A subagent's own background Bash job completing. +const subagentOwnedTaskNotification = { + type: "system", + subtype: "task_notification", + task_id: "bg-job-of-subagent", + tool_use_id: "toolu_subagent_bash", + status: "completed", + summary: "Background command completed", + output_file: "/tmp/bg-job-of-subagent.output", + uuid: "uuid-task-notification", + session_id: "sess-1", +}; +const helperCompleted = { + type: "system", + subtype: "task_notification", + task_id: "helper-task", + tool_use_id: "toolu_helper", + status: "completed", + summary: "Background helper finished", + output_file: "/tmp/helper-task.output", + uuid: "uuid-helper-completed", + session_id: "sess-1", +}; +const rateLimitEvent = { + type: "rate_limit_event", + rate_limit_info: { status: "allowed" }, + uuid: "uuid-rate-limit", + session_id: "sess-1", +}; + +function createPendingQueryMock(): QueryMock { + return createBaseQueryMock(vi.fn(() => new Promise(() => {}))); +} + +test("a main-session frame opens no turn until Claude starts one", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Main-session output only ever arrives inside a turn Claude opened with system/init, so a + // stray unbound frame is not the start of one. + await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + // The wake-up case autonomous turns exist for: a completed background subagent makes the main + // session start a turn of its own, with no prompt from the user. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + +test("a background subagent's frames never open a turn while the main session is idle", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Claude Code 2.1.280 reports "running" for as long as any background subagent is alive. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + for (const frame of [ + helperTaskStarted, + helperTaskProgress, + subagentOwnedTaskNotification, + rateLimitEvent, + helperCompleted, + ]) { + await internal.routeSdkMessageFromPump(frame, queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + + // The next user message starts its own foreground turn rather than landing on a phantom one. + const { turnId } = await session.startTurn("PING"); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await session.close(); +}); + +test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // The helper's completion arrives outside any turn. Claude then starts one to react to it. + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(helperCompleted, queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.turnState).toBe("autonomous"); + + await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn("hello"); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + // A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but + // starts no new turn. + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock); + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + // A message that arrived while Claude wrote its final answer runs as a turn of its own. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2); + + await session.close(); +}); + +test("session state never opens a turn", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // "running" covers background subagents too, so it says nothing about the main session. + for (const state of ["idle", "running", "requires_action", "idle"] as const) { + await internal.routeSdkMessageFromPump(sessionState(state), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + // State frames are bookkeeping, never timeline content. + expect(events).toEqual([]); + + await session.close(); +}); + +test("an idle session state closes an autonomous turn that no result closed", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // Safety net: Claude says nothing at all is running any more. + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("session state never opens or closes a foreground turn", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + const { turnId } = await session.startTurn("hello"); + + // The send's own running, then an idle that trails the previous turn and lands late. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await internal.routeSdkMessageFromPump( + { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }, + queryMock, + ); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + await session.close(); +}); + +interface InterruptInternals extends SessionStateInternals { + pendingInterruptAbort: boolean; +} + +type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType }; + +function createInterruptibleQueryMock(): InterruptibleQueryMock { + return Object.assign(createPendingQueryMock(), { + cancelAsyncMessage: vi.fn(async () => true), + }); +} + +/** The uuid Paseo stamped on the first message it pushed into the SDK input. */ +async function readFirstPromptUuid(): Promise { + const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable }; + return await createPromptUuidReader(input.prompt)(); +} + +function abortedResult() { + return { + type: "result", + subtype: "error_during_execution", + errors: [], + usage: buildUsage(), + total_cost_usd: 0, + }; +} + +test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("hello"); + // Claude has not acknowledged the message yet: no init, no command_lifecycle "started". + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(queryMock.interrupt).not.toHaveBeenCalled(); + // No result follows an interrupt that never happened, so no flag may wait for one. + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1); + + await session.close(); +}); + +test("an interrupt after Claude took the message but before it started stops it", async () => { + const queryMock = createInterruptibleQueryMock(); + // Claude has dequeued the message for its next turn, so withdrawing it is a no-op. + queryMock.cancelAsyncMessage.mockImplementation(async () => false); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("hello"); + // No command_lifecycle "started" or init has reached Paseo yet. + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(internal.pendingInterruptAbort).toBe(true); + + await session.close(); +}); + +test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => { + const queryMock = createInterruptibleQueryMock(); + let settleWithdrawal: (cancelled: boolean) => void = () => {}; + queryMock.cancelAsyncMessage.mockImplementationOnce( + () => new Promise((resolve) => (settleWithdrawal = resolve)), + ); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + + await session.startTurn("first"); + const firstUuid = await readFirstPromptUuid(); + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + + await session.startTurn("second"); + settleWithdrawal(false); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid); + + await session.close(); +}); + +test("an interrupt while only background subagents run never reaches Claude", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("delegate to a helper"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + // The main session is done; its helper keeps working. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock); + + // Without perTaskStopAffordance, an interrupt here kills every running helper. + await session.interrupt(); + + expect(queryMock.interrupt).not.toHaveBeenCalled(); + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(false); + + await session.close(); +}); + +test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("long task"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + // Claude already started the message, so there is nothing to withdraw. + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(true); + + // A replacement starts before the aborted turn reports its result. + const { turnId } = await session.startTurn("replacement"); + await internal.routeSdkMessageFromPump(abortedResult(), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(queryMock.interrupt).toHaveBeenCalledTimes(1); + + await session.close(); +}); + +test("a new turn clears an interrupt flag no result will consume", async () => { + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // An interrupt that landed just after its turn had ended: Claude emits no result for it. + internal.pendingInterruptAbort = true; + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.pendingInterruptAbort).toBe(false); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. + await internal.routeSdkMessageFromPump( + { ...abortedResult(), errors: ["API Error: 500 upstream failure"] }, + queryMock, + ); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts index d4fc0bcc0c..1223bbc541 100644 --- a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts @@ -78,7 +78,7 @@ describe("subagent lifecycle across an interrupt", () => { queryFactory.mockReset(); }); - async function startInterruptibleTurn() { + async function startInterruptibleTurn(taskStarted: Record = {}) { const channel = buildOpenQueryMock(); queryFactory.mockImplementation(() => channel.query); const session = await new ClaudeAgentClient({ @@ -107,6 +107,7 @@ describe("subagent lifecycle across an interrupt", () => { task_type: "local_agent", subagent_type: "general-purpose", description: "Reply with banana", + ...taskStarted, }); await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0)); @@ -169,6 +170,31 @@ describe("subagent lifecycle across an interrupt", () => { await session.close(); }); + test("a child spawned in the background is not canceled with the turn that spawned it", async () => { + // Claude announces a background spawn on task_started itself and sends no task_updated patch. + const { channel, session, subagentEvents } = await startInterruptibleTurn({ + is_backgrounded: true, + }); + + await session.interrupt(); + channel.push({ + type: "system", + subtype: "task_notification", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + status: "completed", + }); + + await vi.waitFor(() => + expect(subagentEvents).toContainEqual( + expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }), + ), + ); + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]); + + await session.close(); + }); + test("a foreground child is canceled with the turn that spawned it", async () => { const { session, subagentEvents } = await startInterruptibleTurn(); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d88d8105cf..2d970c49de 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -3160,10 +3160,15 @@ describe("ClaudeAgentSession context window usage", () => { }); test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => { - // Trailing output after the foreground result opens an autonomous turn, which starts - // compacting and is then ended by the next foreground turn, never reaching a boundary. + // Claude starts a turn of its own after the foreground result, which starts compacting and is + // then ended by the next foreground turn, never reaching a boundary. const session = await createSessionForTurns([ - [createSuccessResult(), createMessageStartEvent(), createCompactingStatus()], + [ + createSuccessResult(), + createInitMessage(), + createMessageStartEvent(), + createCompactingStatus(), + ], [createCompactingStatus(), createSuccessResult()], ]); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index ddac56a0eb..d505689a95 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -452,6 +452,15 @@ function errorToMessageString(error: unknown): string { return ""; } +/** + * Claude opens every main-session turn with system/init and closes it with exactly one result: + * a prompt, a wake-up after background work, a slash command, an API error. Background subagents + * never emit either on the main stream. + */ +function isClaudeTurnInit(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function firstStringField( input: Record, primaryKey: string, @@ -2070,11 +2079,12 @@ class ClaudeAgentSession implements AgentSession { private activeForegroundQuery: Query | null = null; private activeForegroundInput: AsyncMessageInput | null = null; /** - * Steers pushed into the live SDK input that Claude may not have read yet. Interrupting the turn - * has to discard them, or the SDK dequeues one and resumes the turn we just stopped. SDK user - * UUIDs are provider-private; never let them escape the adapter boundary. + * Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose + * command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK + * dequeues one and resumes the turn we just stopped. SDK user UUIDs are provider-private; never + * let them escape the adapter boundary. */ - private readonly queuedSteerUuids = new Set(); + private readonly unstartedMessageUuids = new Set(); /** Human steers whose text has not reached Claude yet and therefore supersede blocking cards. */ private readonly permissionClearingSteerUuids = new Set(); private claudeSessionId: string | null; @@ -2126,6 +2136,12 @@ class ClaudeAgentSession implements AgentSession { private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; + /** + * Whether Claude's main session is inside one of its own turns: set by system/init and by a + * pushed message's command_lifecycle "started" (/compact works for seconds before its init), + * cleared by the turn's result or an idle session state. See observeMainTurnBracket. + */ + private mainTurnInFlight = false; private foregroundHasVisibleActivity = false; private activeTurnHasAssistantText = false; private readonly contextUsage: ClaudeContextUsageState; @@ -2272,6 +2288,7 @@ class ClaudeAgentSession implements AgentSession { this.contextUsage.beginTurn(); this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); + if (sdkUserMessageId) this.unstartedMessageUuids.add(sdkUserMessageId); let cancelIssued = false; const requestCancel = () => { @@ -2282,13 +2299,18 @@ class ClaudeAgentSession implements AgentSession { if (this.cancelCurrentTurn === requestCancel) { this.cancelCurrentTurn = null; } + // Whether Claude has this message in hand. A late idle from the previous turn can clear + // mainTurnInFlight after Claude started it, so the message's own lifecycle counts too. + const claudeStartedTurn = + this.mainTurnInFlight || + (sdkUserMessageId !== null && !this.unstartedMessageUuids.has(sdkUserMessageId)); this.rejectAllPendingPermissions(new Error("Permission request canceled")); this.finishForegroundTurn({ type: "turn_canceled", provider: "claude", reason: "Interrupted", }); - void this.interruptActiveTurn().catch((error) => { + void this.interruptActiveTurn(claudeStartedTurn).catch((error) => { this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); }; @@ -2298,6 +2320,11 @@ class ClaudeAgentSession implements AgentSession { try { await this.ensureQuery(); + if (cancelIssued) { + // Stopped while Claude was still starting up: withdrawn by never sending it. + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); + return { turnId }; + } if (!this.input) { throw new Error("Claude session input stream not initialized"); } @@ -2311,6 +2338,7 @@ class ClaudeAgentSession implements AgentSession { } }, 0); } catch (error) { + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); this.finishForegroundTurn( this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"), ); @@ -2359,7 +2387,7 @@ class ClaudeAgentSession implements AgentSession { clearPendingPermissions: boolean, ): void { const uuid = message.uuid; - if (uuid) this.queuedSteerUuids.add(uuid); + if (uuid) this.unstartedMessageUuids.add(uuid); if (uuid && clearPendingPermissions) { this.permissionClearingSteerUuids.add(uuid); } @@ -2370,7 +2398,7 @@ class ClaudeAgentSession implements AgentSession { } } catch (error) { if (uuid) { - this.queuedSteerUuids.delete(uuid); + this.unstartedMessageUuids.delete(uuid); this.permissionClearingSteerUuids.delete(uuid); } throw error; @@ -3182,6 +3210,8 @@ class ClaudeAgentSession implements AgentSession { const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + // A fresh Claude process has no turn of its own in flight. + this.mainTurnInFlight = false; this.query = claudeQuery( { prompt: input.iterable, options }, { @@ -3301,7 +3331,12 @@ class ClaudeAgentSession implements AgentSession { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, - overlays: [this.launchEnv], + overlays: [ + this.launchEnv, + // Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes + // an autonomous turn (see consumeSessionStateChange). + { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" }, + ], }); } @@ -3368,6 +3403,10 @@ class ClaudeAgentSession implements AgentSession { ...settingsOptions, // Provider subagent panes render the child's nested transcript. forwardSubagentText: true, + // Stop and replace abort only the main turn, like Esc in Claude Code, and background + // subagents keep running. Without this declaration an interrupt kills every one of them. + // Claude stops a helper itself with its TaskStop tool. + perTaskStopAffordance: true, hooks: this.buildSubagentEffortHooks(), ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, @@ -3898,35 +3937,67 @@ class ClaudeAgentSession implements AgentSession { return false; } - private isAssistantishMessage(message: SDKMessage): boolean { - return ( - message.type === "assistant" || - message.type === "stream_event" || - message.type === "tool_progress" || - (message.type === "system" && message.subtype === "task_notification") - ); + /** + * Claude's account of whether anything in the session is working, which is not the same as the + * main session working: Claude Code 2.1.280 stays "running" for as long as any background + * subagent is alive. So "running" never opens a turn. + * + * "idle" means nothing at all is running, so it closes an autonomous turn whatever opened it. + * It never closes a foreground turn: an idle can trail the previous turn and land just after a + * new send, and it must not end that send. + * + * Returns true when the frame was a state change, which is bookkeeping and never reaches the + * timeline. + */ + private consumeSessionStateChange(message: SDKMessage): boolean { + if (message.type !== "system" || message.subtype !== "session_state_changed") { + return false; + } + if (message.state === "idle") { + this.mainTurnInFlight = false; + this.completeAutonomousTurn(); + } + return true; + } + + /** Tracks mainTurnInFlight from Claude's own turn brackets (isClaudeTurnInit). */ + private observeMainTurnBracket(message: SDKMessage): void { + if (message.type === "result") { + this.mainTurnInFlight = false; + return; + } + if (isClaudeTurnInit(message)) { + this.mainTurnInFlight = true; + // A new turn has begun, so the interrupted one is over. An interrupt that landed after its + // turn had already ended gets no result, and the leftover flag would swallow this turn's. + this.pendingInterruptAbort = false; + return; + } + if (readClaudeCommandLifecycle(message)?.state === "started") { + this.mainTurnInFlight = true; + } } /** - * Claude keeps talking about the request it was told to kill — the notification for the tool it - * just stopped, trailing assistant output. That is not new work, so it must not open a turn: the - * only message that would close that turn is the result shouldSuppressStaleResult drops, leaving - * the agent stuck reporting "running" forever. + * Only the main session's own turn start opens an autonomous turn. Everything a background + * subagent emits (its task frames, its own background jobs' notifications, rate limit events) + * arrives outside one, and no result would ever close a turn it opened. */ - private shouldStartAutonomousTurn(message: SDKMessage): boolean { - if (this.activeForegroundTurnId || this.pendingInterruptAbort) { - return false; - } - return this.isAssistantishMessage(message); + private opensAutonomousTurn(message: SDKMessage): boolean { + return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } private async routeSdkMessageFromPump(message: SDKMessage): Promise { + if (this.consumeSessionStateChange(message)) { + return; + } + this.observeMainTurnBracket(message); if (this.shouldSuppressStaleResult(message)) { return; } const isForeground = Boolean(this.activeForegroundTurnId); - if (this.shouldStartAutonomousTurn(message)) { + if (this.opensAutonomousTurn(message)) { this.startAutonomousTurn(); } if (!isForeground && !this.autonomousTurn && message.type === "result") { @@ -4068,7 +4139,13 @@ class ClaudeAgentSession implements AgentSession { return true; } - private async interruptActiveTurn(): Promise { + /** + * Interrupts Claude only when a turn of its main session is running. With nothing running there + * is nothing to stop but our own queued messages, so those are withdrawn instead: an interrupt + * would reach only background helpers (and kill them on a CLI that ignores + * perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort. + */ + private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise { const queryToInterrupt = this.query; if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { this.logger.trace( @@ -4082,8 +4159,16 @@ class ClaudeAgentSession implements AgentSession { ); return; } - this.pendingInterruptAbort = true; - await this.discardQueuedSteers(queryToInterrupt); + if (claudeStartedTurn) { + this.pendingInterruptAbort = true; + await this.withdrawUnstartedMessages(queryToInterrupt); + } else { + // Claude can dequeue a message for its next turn before announcing it started; withdrawing + // it is then a no-op, and only an interrupt stops it. Withdraw once: a prompt sent while + // this withdrawal was in flight belongs to the next turn. + if (await this.withdrawUnstartedMessages(queryToInterrupt)) return; + this.pendingInterruptAbort = true; + } try { await this.awaitWithTimeout( queryToInterrupt.interrupt(), @@ -4095,14 +4180,15 @@ class ClaudeAgentSession implements AgentSession { } /** - * Interrupt means interrupt: a steer Claude never read dies with the turn instead of resuming it. - * A steer already dequeued cannot be recalled, and does not need to be — the interrupt kills it. + * Interrupt means interrupt: a message Claude never started dies with the turn instead of + * resuming it. One already dequeued cannot be recalled, and does not need to be, since the + * interrupt kills it. Returns whether Claude confirmed every message withdrawn. */ - private async discardQueuedSteers(query: Query): Promise { - const uuids = [...this.queuedSteerUuids]; - this.queuedSteerUuids.clear(); + private async withdrawUnstartedMessages(query: Query): Promise { + const uuids = [...this.unstartedMessageUuids]; + this.unstartedMessageUuids.clear(); this.permissionClearingSteerUuids.clear(); - if (uuids.length === 0) return; + if (uuids.length === 0) return true; // The SDK runtime supports this, but its public Query type has not caught up. Keep the // compatibility escape hatch inside the Claude adapter. const cancelAsyncMessage = ( @@ -4110,14 +4196,17 @@ class ClaudeAgentSession implements AgentSession { cancelAsyncMessage?: (uuid: string) => Promise; } ).cancelAsyncMessage; - if (!cancelAsyncMessage) return; + if (!cancelAsyncMessage) return false; + let withdrewAll = true; for (const uuid of uuids) { try { - await cancelAsyncMessage.call(query, uuid); + if (!(await cancelAsyncMessage.call(query, uuid))) withdrewAll = false; } catch (error) { - this.logger.warn({ err: error }, "Failed to discard a queued Claude steer"); + withdrewAll = false; + this.logger.warn({ err: error }, "Failed to withdraw a queued Claude message"); } } + return withdrewAll; } private translateMessageToEvents( @@ -4166,7 +4255,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4200,11 +4289,14 @@ class ClaudeAgentSession implements AgentSession { return events; } - /** Once Claude has read a steer there is nothing left to discard on interrupt. */ - private forgetReadSteer(message: unknown): void { + /** + * Once Claude has started a message, or cancelled it, there is nothing left to withdraw on + * interrupt. + */ + private forgetStartedMessage(message: unknown): void { const lifecycle = readClaudeCommandLifecycle(message); if (!lifecycle || lifecycle.state === "queued") return; - this.queuedSteerUuids.delete(lifecycle.commandUuid); + this.unstartedMessageUuids.delete(lifecycle.commandUuid); this.permissionClearingSteerUuids.delete(lifecycle.commandUuid); } @@ -6519,7 +6611,8 @@ function extractClaudeUserText(messageRaw: unknown): string | null { interface ClaudeCommandLifecycle { commandUuid: string; - state: "queued" | "started" | "completed"; + /** "cancelled" is terminal too: API errors, interrupts and "now" pre-emption end a message. */ + state: "queued" | "started" | "completed" | "cancelled"; } /** Runtime-only Claude frames are validated here because the SDK's public union omits them. */ @@ -6528,7 +6621,7 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | if (record?.type !== "command_lifecycle") return null; if ( typeof record.command_uuid !== "string" || - !["queued", "started", "completed"].includes(String(record.state)) + !["queued", "started", "completed", "cancelled"].includes(String(record.state)) ) { return null; } diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 1eca4cedc2..b0bfc3d755 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -511,6 +511,33 @@ describe("ClaudeTaskProtocolSource", () => { expect(source.cancelRunningForegroundTasks()).toEqual([]); }); + it("does not cancel a subagent announced as backgrounded at spawn", () => { + // Claude sets is_backgrounded on task_started for a child spawned in the background, and + // sends no task_updated patch for it. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a resumed subagent, which Claude always runs in the background", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false })); + source.observe(taskUpdated("completed")); + source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still cancels a subagent announced in the foreground", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: false })); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + it("still routes a backgrounded subagent that settles after the interrupt", () => { // The headline case: interrupt, continue, and the child that was told to outlive the turn // reports completion later. Wiping the routing table on cancel drops this on the floor and diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 1a44520366..2149fae139 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -16,7 +16,8 @@ import { * * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): * - * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_started task_id, tool_use_id, description, subagent_type, task_type, + * is_backgrounded * task_updated task_id, patch.status, patch.is_backgrounded * task_notification task_id, tool_use_id, status * @@ -39,6 +40,11 @@ interface TaskStartedMessage { task_type?: string; prompt?: string; skip_transcript?: boolean; + /** + * Set at spawn: true for a task registered in the background. A resumed subagent is always + * registered in the background. + */ + is_backgrounded?: boolean; } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ @@ -71,8 +77,8 @@ interface TaskUpdatedMessage { task_id: string; /** * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground - * task is backgrounded, which is the only signal that separates a child that dies with its turn - * from one that was explicitly told to outlive it. + * task is moved to the background after `task_started`. Together with `task_started`'s own + * flag it separates a child that dies with its turn from one told to outlive it. */ patch?: { status?: string; is_backgrounded?: boolean }; } @@ -171,8 +177,8 @@ export class ClaudeTaskProtocolSource { /** Workflow invocations already own a real Workflow card in the parent timeline. */ private readonly idsWithExistingParentToolCard = new Set(); /** - * Declared subagents that were moved to the background. They outlive the turn that spawned - * them, so a turn ending is not evidence that they stopped. + * Declared subagents running in the background, whether spawned there or moved there later. + * They outlive the turn that spawned them, so a turn ending is not evidence that they stopped. */ private readonly backgroundedIds = new Set(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ @@ -331,6 +337,7 @@ export class ClaudeTaskProtocolSource { this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); + this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); } @@ -414,14 +421,15 @@ export class ClaudeTaskProtocolSource { private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { const id = this.subagentIdByTaskId.get(message.task_id); - const backgrounded = message.patch?.is_backgrounded; - if (id && typeof backgrounded === "boolean") { - if (backgrounded) this.backgroundedIds.add(id); - else this.backgroundedIds.delete(id); - } + if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded); return this.observeStatus(message.task_id, message.patch?.status); } + private recordBackgrounded(id: string, backgrounded: boolean | undefined): void { + if (backgrounded === true) this.backgroundedIds.add(id); + else if (backgrounded === false) this.backgroundedIds.delete(id); + } + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { const observations = this.observeWorkflowResult(message); observations.push(...this.observeUsage(message.task_id, message.usage)); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 2471ff8c5a..5eea6dab24 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -304,6 +304,166 @@ async function waitForRunningClaudeSleep( ); } +// Explicit point-in-time polls of agent status, on top of (not instead of) the pushed +// agent_update events the collector already records. A phantom "running" flip that starts and +// ends between two polls would still be caught by the pushed-event scan the callers pair this +// with; polling here additionally proves the daemon answers fetchAgent with "idle" throughout, +// not just that it eventually pushes one. +async function sampleAgentStatuses( + client: DaemonClient, + agentId: string, + samples: number, + intervalMs: number, +): Promise { + const statuses: string[] = []; + for (let i = 0; i < samples; i += 1) { + const snapshot = await client.fetchAgent({ agentId }); + statuses.push(snapshot?.agent.status ?? "missing"); + if (i < samples - 1) { + await sleep(intervalMs); + } + } + return statuses; +} + +// NOTE: agent_stream events on the wire (AgentStreamEventPayloadSchema in +// packages/protocol/src/messages.ts) do not include a "provider_subagent" variant. A backgrounded +// subagent's descriptor updates are forwarded exclusively as separate "agent.provider_subagents.update" +// messages (see forwardProviderSubagentUpdate in session.ts), and that push channel's delivery is +// gated by capabilities this test's plain DaemonClient does not declare. list_provider_subagents is a +// plain request/response RPC unaffected by that gating, so status history below polls it directly +// instead of trying to reconstruct status transitions from the stream. +type ProviderSubagentDescriptor = Awaited< + ReturnType +>["subagents"][number]; + +interface ProviderSubagentPoll { + subagent: ProviderSubagentDescriptor; + // Every status observed while polling, in order, so a transient cancel cannot hide behind a + // later "completed" read even though the store's status field is not expected to un-cancel. + history: string[]; +} + +// Polls agent.provider_subagents.list until the backgrounded helper is registered at all. +// "Main's turn is running" (waitForAgentUpsert) fires on generic turn-start bookkeeping, which +// can be true before Claude has actually issued the Agent tool call; canceling that early +// cancels a turn with no helper launched yet, which is not what "Stop spares a helper" means to +// test. This confirms the helper actually exists before the caller acts on it. +async function waitForProviderSubagentToExist( + client: DaemonClient, + agentId: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + if (subagents[0]) { + return subagents[0]; + } + await sleep(300); + } + throw new Error( + `Timed out waiting for a background helper to be registered for agent ${agentId}`, + ); +} + +// Claude starts the wake-up turn a moment after the helper's completion lands (0.1 to 5 s in +// recorded traces), so poll for it instead of checking the instant the helper reads completed. +// The daemon announces an autonomous turn to subscribers as a pushed "running" agent_update, +// not as an agent_stream turn_started (that stream reaches the client that started a run). +async function waitForWakeTurn( + collector: ReturnType, + agentId: string, + fromIndex: number, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const window = collector.messages.slice(fromIndex); + const woke = + getAgentStatuses(window, agentId).includes("running") || + window.some( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "turn_started", + ); + if (woke) return true; + await sleep(500); + } + return false; +} + +// The wake-up turn runs briefly after it opens, so the final state is read once it settles. +async function waitForAgentStatus( + client: DaemonClient, + agentId: string, + status: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: string | undefined; + while (Date.now() < deadline) { + last = (await client.fetchAgent({ agentId }))?.agent.status; + if (last === status) return last; + await sleep(500); + } + return last; +} + +// Polls agent.provider_subagents.list until the single backgrounded helper leaves "running". +// Scenarios in this file always launch exactly one background helper, so subagents[0] is it. +async function waitForProviderSubagentTerminal( + client: DaemonClient, + agentId: string, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const history: string[] = []; + let lastSeen: ProviderSubagentDescriptor[] = []; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + lastSeen = subagents; + const subagent = subagents[0]; + if (subagent) { + history.push(subagent.status); + if (subagent.status !== "running") { + return { subagent, history }; + } + } + await sleep(1_000); + } + throw new Error( + `Timed out waiting for the background helper to leave "running". history=${JSON.stringify(history)} lastSeen=${JSON.stringify(lastSeen)}`, + ); +} + +// Pulls the main agent's own assistant text via fetchAgentTimeline rather than the push +// collector. Assistant text delivery is paced (docs/agent-stream-performance.md), so checking +// the collector immediately after waitForFinish resolves can still miss the final text; the +// timeline fetch is authoritative and matches what the pre-existing tests in this file do. +async function fetchAssistantTexts( + client: DaemonClient, + agentId: string, + limit = 100, +): Promise { + const timeline = await client.fetchAgentTimeline(agentId, { limit }); + return timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => (entry.item as Extract).text); +} + +async function fetchSubagentAssistantTexts( + client: DaemonClient, + agentId: string, + subagentId: string, +): Promise { + const timeline = await client.fetchProviderSubagentTimeline(agentId, subagentId, { limit: 50 }); + return timeline.rows + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item as Extract).text); +} + describe("daemon E2E (real claude) - send message during tool call", () => { let canRun = false; interface SteeringResources { @@ -504,6 +664,141 @@ describe("daemon E2E (real claude) - send message during tool call", () => { } }, 210_000); + test("a background subagent keeps running after a message is sent while it works", async () => { + // The scenario the rest of this file explicitly rules out: every other test here tells Claude + // "Do not use a background task", so steering during a backgrounded subagent was never + // covered. A subagent streams on the parent's channel, so a parent that mistakes the child's + // frames for its own work reports "running" after it has already finished, and the message + // sent into that window is refused, or delivered by cancelling the turn and taking the + // subagent with it. Both outcomes are user-visible, and both are what this asserts against. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Claude background-subagent test client was not created"); + await within("connect background-subagent test client", 15_000, client.connect()); + await within( + "subscribe background-subagent test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create background-subagent test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-background-survives-send", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + await within( + "launch one background subagent and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly: sleep 40.`, + `Wait for its completion notification, then reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // The main session has ended its turn. It must read as idle even though the subagent it + // launched is still streaming on the shared channel. + const afterLaunch = await within( + "wait for the main session to go idle after launching the subagent", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect( + afterLaunch.status, + "a main session that finished its turn must not report busy because a subagent is running", + ).not.toBe("running"); + + // And a message sent in that window must be accepted rather than refused. + const followUpId = generateClientMessageId(); + await within( + "send a message to the idle main session while the subagent runs", + 30_000, + client.sendAgentMessage(agent.id, "Are you there? Reply exactly: MAIN_ALIVE.", { + messageId: followUpId, + }), + ); + await within( + "wait for the main session to answer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + + const timeline = await within( + "fetch background-subagent timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 200 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract).text, + ) + .join("\\n"); + expect( + assistantText, + "the main session must be reachable while its background subagent still runs", + ).toContain("MAIN_ALIVE"); + + // The subagent must have survived the send rather than being cancelled with the turn. Its + // status is read from agent.provider_subagents.list (see the note above + // waitForProviderSubagentToExist): agent_stream carries no provider_subagent events. + const { subagents } = await within( + "list provider subagents after the send", + 15_000, + client.listProviderSubagents(agent.id), + ); + expect(subagents.length, "the background subagent must be tracked").toBeGreaterThan(0); + expect( + subagents.map((subagent) => subagent.status), + "a backgrounded subagent must not be canceled by a message sent to the main session", + ).not.toContain("canceled"); + const { subagent, history } = await within( + "wait for the background subagent to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect(history, "no status poll may read canceled").not.toContain("canceled"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Claude background-subagent E2E cleanup failures").toEqual([]); + } + }, 300_000); + test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); @@ -678,4 +973,430 @@ describe("daemon E2E (real claude) - send message during tool call", () => { rmSync(cwd, { recursive: true, force: true }); } }, 300_000); + + test("a helper's own nested background job does not phantom-run the parent or kill the helper", async () => { + // The exact reported bug: main backgrounds a helper, the helper itself starts its OWN + // background job (a grandchild from main's perspective) and then keeps working in the + // foreground. The grandchild's completion is bookkeeping that belongs to the helper, not to + // main, and must not make main phantom-report "running" or get treated as busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("nested background-job test client was not created"); + await within("connect nested background-job test client", 15_000, client.connect()); + await within( + "subscribe nested background-job test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create nested background-job test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-nested-background-job", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch a helper with its own nested background job and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "nested bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly:`, + `python3 -c 'import time; time.sleep(5)'.`, + `Immediately after starting it, without waiting for it, use the Bash tool again`, + `in the foreground to run exactly: python3 -c 'import time; time.sleep(50)'.`, + `After that foreground command finishes, reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + const afterLaunch = await within( + "wait for the main session to go idle after launching the nested-background helper", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(afterLaunch.status, "the main session must be idle once it replied LAUNCHED").toBe( + "idle", + ); + + // The helper's own nested background job (5s) finishes while the helper is still busy + // in its unrelated foreground sleep (50s). Sample for 30s, well past the nested job's + // completion even when the helper is slow to start it, + // both by polling and by scanning every pushed agent_update in the window, so a brief + // phantom flip cannot slip between polls. + const sampleWindowStart = resources.collector.messages.length; + const polledStatuses = await sampleAgentStatuses(client, agent.id, 11, 3_000); + const pushedStatuses = getAgentStatuses( + resources.collector.messages.slice(sampleWindowStart), + agent.id, + ); + const observedRunning = [...polledStatuses, ...pushedStatuses].filter( + (status) => status === "running", + ); + expect( + observedRunning, + `the parent must stay idle across the nested job's completion; polled=${JSON.stringify(polledStatuses)} pushed=${JSON.stringify(pushedStatuses)}`, + ).toEqual([]); + + // A message sent in that window must still be answered normally. + await within( + "send PONG request while the nested job settles", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + }), + ); + await within("wait for the PONG reply", 60_000, client.waitForFinish(agent.id, 50_000)); + // Pulled via fetchAgentTimeline, not the push collector: assistant text delivery is paced + // (docs/agent-stream-performance.md) and can trail waitForFinish's own completion signal, + // so the collector can still be missing the final text the instant waitForFinish resolves. + const pongTexts = await within( + "fetch timeline for the PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect( + pongTexts.join("\n"), + "the agent must answer PONG while the helper is still running", + ).toContain("PONG"); + + const messagesBeforeWake = resources.collector.messages.length; + + // The helper itself must survive: never recorded canceled, and it eventually reports + // CHILD_DONE from its own foreground sleep finishing. + const { subagent, history } = await within( + "wait for the nested-background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + // The helper's completion should wake the main session with a fresh turn (the + // session-state-driven autonomous turn), separate from the PONG exchange above. + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeWake, + ); + if (!wokeMainAgent) { + const wakeWindowMessages = resources.collector.messages.slice(messagesBeforeWake); + const wakeWindowEventTypes = wakeWindowMessages + .filter( + (message) => message.type === "agent_stream" && message.payload.agentId === agent.id, + ) + .map((message) => + message.type === "agent_stream" ? message.payload.event.type : "unknown", + ); + const timeline = await client + .fetchAgentTimeline(agent.id, { limit: 100 }) + .catch(() => null); + throw new Error( + `the helper's completion must wake the main session with a new turn. ` + + `wakeWindowEventTypes=${JSON.stringify(wakeWindowEventTypes)} ` + + `recentTimeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : null)}`, + ); + } + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "nested background-job E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("the app's default steer survives a backgrounded helper while the main session works", async () => { + // appSettings.sendBehavior defaults to "steer" (packages/app/src/hooks/use-settings/storage.ts), + // so this is what the app actually sends when a user replies while an agent looks busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("default-steer background-helper test client was not created"); + await within("connect default-steer test client", 15_000, client.connect()); + await within( + "subscribe default-steer test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create default-steer test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-default-steer-background-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor b".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(42)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(16)'.", + // Matching the proven pattern from the first test in this file: tell Claude to wait + // for a later message rather than relying on the sleep call itself to block, since a + // Bash call can resolve through Claude Code's own async task_notification path once a + // background subagent is already active instead of blocking the turn. + "Do not reply or finish your turn until a later user message arrives, no matter how", + "quickly that Bash command finishes. When that later message arrives, address it and", + "end your turn. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the daemon considers main's own turn active before steering it. This does not + // wait for a specific "sleep 16" shell call to show as running: Claude Code 2.1.280 can + // report a slower Bash call through its own async task_notification tool instead of a + // classic blocking call once a background subagent is already active, so pinning to a + // literal running shell tool_call is not reliable here. Busy at the daemon level is what + // "while main is busy with its own sleep" actually needs. + await within( + "confirm main's own turn is active before steering", + 30_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 25_000), + ); + // The running status precedes the Agent tool call, so a steer sent on it alone can reach + // Claude before the helper exists and prove nothing about a helper surviving it. + await within( + "confirm the background helper is registered before steering", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + await within( + "send the app's default steer while main is busy with its own sleep", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + activeTurnBehavior: "steer", + }), + ); + + const finish = await within( + "wait for the main session to finish after the steer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(finish.status, "the main session must end idle after the steer").toBe("idle"); + // Pulled via fetchAgentTimeline, not the push collector: see fetchAssistantTexts. + const postSteerTexts = await within( + "fetch timeline for the post-steer PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect(postSteerTexts.join("\n"), "PONG must be answered after the steer").toContain("PONG"); + + const { subagent, history } = await within( + "wait for the background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "default-steer background-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("Stop cancels the main session's own turn but spares its background helper", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Stop-spares-helper test client was not created"); + await within("connect Stop-spares-helper test client", 15_000, client.connect()); + await within( + "subscribe Stop-spares-helper test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create Stop-spares-helper test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-stop-spares-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor c".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(43)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(23)'.", + "Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the helper has actually been launched (registered) before Stop, not just that + // main's turn shows "running": that status can flip true on generic turn-start bookkeeping + // before Claude has issued the Agent tool call, and canceling that early cancels a turn + // with no helper to spare yet. + await within( + "confirm the background helper is registered before Stop", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + const messagesBeforeStop = resources.collector.messages.length; + await within("Stop the main session's own turn", 30_000, client.cancelAgent(agent.id)); + const afterStop = await within( + "wait for the agent to go idle after Stop", + 60_000, + client.waitForFinish(agent.id, 50_000), + ); + expect(afterStop.status, "Stop must leave the agent idle").toBe("idle"); + + // The helper must survive Stop: it keeps running and later reports CHILD_DONE, which + // wakes the main session with a fresh autonomous turn. + const { subagent, history } = await within( + "wait for the background helper to finish after Stop", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "Stop must not cancel or fail the background helper").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeStop, + ); + expect( + wokeMainAgent, + "the helper's completion after Stop must wake the main session with a new turn", + ).toBe(true); + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Stop-spares-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); }); From 2a5264c188c7a39922e14b3f8b400e818da1cb34 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 15:05:53 +0200 Subject: [PATCH 131/281] docs: changelog for 0.11.0 --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4c3ab914..6b60e3d0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ # Changelog -## 0.11.0-beta.5 - 2026-10-06 +## 0.11.0 - 2026-10-07 ### Added - Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) - Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) -- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921)) +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216)) - Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089)) - Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) - Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) @@ -16,9 +16,12 @@ - Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) - Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) - Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1)) - Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780)) - Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) - Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263)) +- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226)) - Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) - Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151)) - Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) @@ -27,10 +30,9 @@ ### Changed -- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps. - +- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) - Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) -- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975)) +- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185)) - Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) - Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot)) - Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) @@ -46,12 +48,18 @@ ### Fixed - Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) -- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100)) +- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson)) +- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272)) +- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248)) +- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268)) - Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022)) - Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089)) - Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074)) - Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) - Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273)) +- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228)) - Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045)) - Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) - Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) @@ -66,12 +74,14 @@ - Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) - Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112)) - Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115)) +- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215)) - Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061)) - Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) - Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) - Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) - Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) - Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188)) - Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum)) - Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988)) - Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) @@ -85,12 +95,18 @@ - Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774)) - Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804)) - Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) -- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172)) - Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970)) - Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138)) - Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158)) +- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195)) - Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207)) +- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219)) +- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd)) +- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq)) +- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn)) - Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen)) - Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) - Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) @@ -98,12 +114,16 @@ - Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) - Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) +- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279)) +- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117)) - Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097)) - Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095)) - Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) +- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255)) - Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) - Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart)) - Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) @@ -123,6 +143,7 @@ - Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet)) - Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) - Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239)) - Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) - Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) - Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) From 22488d4502cd0c8a4ee4619af991762f071b60a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 15:27:08 +0200 Subject: [PATCH 132/281] chore(release): cut 0.11.0 --- .../android/en-US/changelogs/110001.txt | 7 +++ .../android/en-US/changelogs/110002.txt | 7 +++ .../android/en-US/changelogs/110003.txt | 7 +++ .../android/en-US/changelogs/110004.txt | 7 +++ package-lock.json | 56 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++-- packages/website/package.json | 2 +- plugins/package.json | 2 +- 18 files changed, 83 insertions(+), 55 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/110001.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/110002.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/110003.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/110004.txt diff --git a/fastlane/metadata/android/en-US/changelogs/110001.txt b/fastlane/metadata/android/en-US/changelogs/110001.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110001.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110002.txt b/fastlane/metadata/android/en-US/changelogs/110002.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110002.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110003.txt b/fastlane/metadata/android/en-US/changelogs/110003.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110003.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110004.txt b/fastlane/metadata/android/en-US/changelogs/110004.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110004.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index a2856c464a..c4b456a98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.11.0-beta.5", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.11.0-beta.5", + "version": "0.11.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36413,7 +36413,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37496,12 +37496,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/server": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/server": "0.11.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37748,10 +37748,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/relay": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/relay": "0.11.0", "zod": "^4.4.3" }, "devDependencies": { @@ -37762,7 +37762,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.5", + "version": "0.11.0", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38006,7 +38006,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.11.0-beta.5", + "version": "0.11.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38902,7 +38902,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38931,14 +38931,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38948,8 +38948,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38971,7 +38971,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38998,7 +38998,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -39216,16 +39216,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/highlight": "0.11.0-beta.5", - "@getpaseo/plugin": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/relay": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/highlight": "0.11.0", + "@getpaseo/plugin": "0.11.0", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/relay": "0.11.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -40146,7 +40146,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -41754,7 +41754,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.5", + "version": "0.11.0", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 3eebf95522..2805417f66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.11.0-beta.5", + "version": "0.11.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index f23f06ce52..d92e69638b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.11.0-beta.5", + "version": "0.11.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 08525ae169..875e73e0f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/server": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/server": "0.11.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 05e4b208d4..063786f4ae 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/relay": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/relay": "0.11.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9819f719c4..b76528d69e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.5", + "version": "0.11.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 934b386778..bf2a2b056d 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index a2b5b5b81e..3541693c33 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.5", + "version": "0.11.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 306a636512..81c05a06e0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -70,8 +70,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -81,8 +81,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/protocol": "0.11.0", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b969849cb1..6c1cee2cba 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 58c0f442cd..a9d2d6c86e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index c11efb3571..d9424c7e9b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.11.0-beta.5", + "version": "0.11.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.5", - "@getpaseo/highlight": "0.11.0-beta.5", - "@getpaseo/plugin": "0.11.0-beta.5", - "@getpaseo/protocol": "0.11.0-beta.5", - "@getpaseo/relay": "0.11.0-beta.5", + "@getpaseo/client": "0.11.0", + "@getpaseo/highlight": "0.11.0", + "@getpaseo/plugin": "0.11.0", + "@getpaseo/protocol": "0.11.0", + "@getpaseo/relay": "0.11.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index bf40c0e1a6..2cb1fad0dd 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.11.0-beta.5", + "version": "0.11.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index a932782991..537cbf0bdf 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.5", + "version": "0.11.0", "private": true, "type": "module", "scripts": { From 1fae8e89f701d117d86a3b5677223407b52c40ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 14:11:10 +0200 Subject: [PATCH 133/281] fix(website): mute footer column titles by shade instead of weight --- packages/website/src/components/site-footer.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 1ab11f9bdb..198a60c9ce 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -19,7 +19,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {