From d3c76be9c16f3dc2090bafe9a27959b59ccb20b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 01:20:53 +0100 Subject: [PATCH 01/38] 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 02/38] 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 03/38] 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 04/38] 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 05/38] 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 06/38] 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 07/38] 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 08/38] 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 09/38] 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 10/38] 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 11/38] 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 12/38] 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 13/38] 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 14/38] 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 15/38] 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 16/38] 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 17/38] 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 18/38] 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 19/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 24/38] 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 25/38] 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 26/38] 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 27/38] 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 28/38] 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 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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 34/38] 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 35/38] 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 36/38] 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 37/38] 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 (