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/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/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/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/docs/plugins.md b/docs/plugins.md index ab12fe7dd2..510ef68af1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -115,27 +115,42 @@ 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 + +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. + +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 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 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 owner/repository +paseo plugin install https://gitlab.com/group/repository.git +paseo plugin install 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 @@ -473,7 +488,7 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery 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/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/nix/npm-deps.hash b/nix/npm-deps.hash index f42dbcdbf3..fa431c78ff 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-L1gb8LCZKPnK6T9XabQ6ctrvyUJnM+TPUCAayssVPMg= +sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc= diff --git a/package-lock.json b/package-lock.json index 8a71d40f8c..01d3bd7f4e 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", @@ -40159,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/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..feceed7869 --- /dev/null +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -0,0 +1,152 @@ +import { test } from "../support/fixtures"; +import { expect } from "@playwright/test"; +import { + findScrollJumps, + expectImageSpaceReserved, + 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, + scrollTop: 900, + 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, + scrollTop: 0, + 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); +}); + +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, + }, 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); + }); + }); +} + +test("reserves image space before its response arrives", async ({ page }, testInfo) => { + await expectImageSpaceReserved(page, testInfo); +}); 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/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/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index f8e728f3b2..32a6d7048e 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,34 +1,40 @@ -import type { Locator } from "@playwright/test"; -import type { UsageReportEntry } from "@getpaseo/protocol/messages"; -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 { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { - installUsageReportsFixture, - type UsageReportsFixture, -} from "../support/helpers/usage-reports"; + 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 USAGE_ICON = - ''; - -function contextUsageReport(): UsageReportEntry { - return { - id: "mock:account", - account: { label: "dev@example.com" }, - fetchedAt: "2026-01-01T00:00:00.000Z", - sourceId: "mock", - sourceLabel: "Mock plan", - icon: USAGE_ICON, - report: { - status: "available", - windows: [{ id: "session", label: "Session", usedPct: 42 }], - }, - }; -} - -function forcedRefreshRequests(usage: UsageReportsFixture) { - return usage.listRequests().filter((request) => request.forceRefresh); -} +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. @@ -95,52 +101,191 @@ test.describe("context window meter", () => { await session.cleanup(); } }); - - test("shows host plan limits and lets keyboard users enter the context tooltip", async ({ - page, - }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { lists: [[contextUsageReport()]] }); - const session = await seedMockAgentWorkspace({ - repoPrefix: "context-window-usage-", - title: "Context window usage tooltip", - initialPrompt: "emit 32000 byte file agent stream payload", - }); - try { - await openAgentRoute(page, session); - await expectComposerVisible(page); - const meter = page.getByTestId("context-window-meter"); - await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); - await usage.waitForListRequests(1); - - // The preceding Tab marks this as keyboard focus; tooltips deliberately ignore restored focus. - await page.keyboard.press("Tab"); - await meter.focus(); - - const tooltip = page.getByTestId("context-window-meter-tooltip"); - await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); - await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); - const refresh = tooltip.getByRole("button", { name: "Refresh Mock plan" }); - await page.keyboard.press("Tab"); - await expect(refresh).toBeFocused(); - await page.mouse.move(0, 0); - await page.waitForTimeout(150); // Longer than the trigger's close grace period. - await expect(refresh).toBeFocused(); - await expect(tooltip).toBeVisible(); - await page.keyboard.press("Enter"); - await expect - .poll(() => forcedRefreshRequests(usage)) - .toEqual([{ forceRefresh: true, reportIds: ["mock:account"] }]); - - const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); - await page.keyboard.press("Tab"); - await expect(pin).toBeFocused(); - await page.keyboard.press("Space"); - await expect(pin).toBeChecked(); - await pin.click(); - await expect(pin).not.toBeChecked(); - } finally { - await session.cleanup(); - } - }); }); + +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, with accessible Refresh", async () => { + const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + await expectRefreshButtons(tooltip, 2); + const meter = page.getByRole("button", { name: "Context window 25% used" }); + await meter.focus(); + await page.keyboard.press("Tab"); + await expect(tooltip.getByRole("button", { name: "Refresh Claude" })).toBeFocused(); + await page.mouse.move(0, 0); + await page.waitForTimeout(150); + await expect(tooltip).toBeVisible(); + 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/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..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, }) => { @@ -279,7 +309,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/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/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-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/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/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/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 00f80ff4cb..91e8e5826a 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,7 +297,40 @@ 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: "" }, + { 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 useLinuxKeyboardPlatform(page); await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); await expect(query(page)).toBeFocused(); 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/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/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/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/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts new file mode 100644 index 0000000000..b35a8373df --- /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("button", { 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/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/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/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts new file mode 100644 index 0000000000..01055ce2c1 --- /dev/null +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -0,0 +1,537 @@ +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 = scroll.querySelectorAll("[data-history-row-id]").length; + let unmounted = 0; + 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(); + }; + 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 }; + }); + // 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); + 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(); + mutations.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 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 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; + // 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 && + 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. + 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[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)); + 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 attachTimelineScreenshot(page, testInfo, "scroll-end"); + 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( + 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", + ).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" }); + }; +} + +/** 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/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/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/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/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/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts new file mode 100644 index 0000000000..74b2bd5bcd --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -0,0 +1,122 @@ +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("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); + 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..4299f1d9c8 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -0,0 +1,45 @@ +interface RowGeometry { + id: string; + top: number; + height: number; +} + +// 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 = 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 : 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 c798c374d4..25e7a313d2 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -9,10 +9,13 @@ import React, { } from "react"; import { defaultRangeExtractor, + observeElementOffset, 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"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -20,10 +23,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 +50,6 @@ interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } -interface HistoryStartPrependAnchor { - progressKey: string; - rowId: string; - viewportOffset: number; -} - type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; @@ -67,24 +62,12 @@ 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) => ({ 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", @@ -304,6 +287,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool olderHistoryProgressKey, scrollEnabled, contentMaxWidth, + imageContext, isMobileBreakpoint, } = props; const historyVirtualized = useRevisedHistoryRows( @@ -348,14 +332,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); @@ -389,14 +372,56 @@ 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); - if (!chatFindRowIndexes) return visible; - return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right); + (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([ + ...defaultRangeExtractor(visibleRange), + ...(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 contentWidth = contentRef.current?.getBoundingClientRect().width; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, @@ -404,31 +429,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; - return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; + const measured = row && measuredRowHeights.current.get(row.id); + if (measured && measured.width === contentWidth) return measured.height; + return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 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]); + 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; const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { @@ -442,7 +455,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 +468,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 +507,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 +517,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 +536,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 +603,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; @@ -731,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(() => { @@ -769,6 +696,44 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); + const reconcileReadingPosition = useStableEvent(() => { + 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 }; + }, + ); + const shouldAnchor = !followOutputRef.current && !isJumpSettling(); + if (!shouldAnchor) readingAnchor.reset(); + const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + if (!shouldAnchor) { + readingAnchor.reset(); + return; + } + 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(); + }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); + const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); if (followOutputRef.current) { @@ -801,11 +766,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } lastKnownScrollTopRef.current = currentScrollTop; + if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) { + readingAnchor.scroll(currentScrollTop); + } updateScrollMetrics(); evaluateHistoryStart(); }, [ evaluateHistoryStart, isJumpSettling, + readingAnchor, stopFollowingOutputFromUserIntent, updateScrollMetrics, ]); @@ -814,8 +783,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 +794,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 +927,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } else { lastObservedViewportGeometryRef.current = nextGeometry; } - if (historyStartPrependAnchorActiveRef.current) { - applyHistoryStartPrependAnchor(); - } + reconcileReadingPosition(); if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartPrependSettle(); } @@ -980,7 +946,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool observer.disconnect(); }; }, [ - applyHistoryStartPrependAnchor, + reconcileReadingPosition, evaluateHistoryStart, isActive, reportReadingPosition, @@ -1101,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 }); @@ -1115,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); @@ -1188,9 +1156,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/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", () => { @@ -147,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", () => { @@ -166,56 +208,9 @@ describe("estimateStreamItemHeight", () => { timestamp: createTimestamp(2), }; - expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220); - }); -}); - -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); + 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 13bc737e5f..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 { @@ -10,21 +11,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; }; @@ -58,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/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/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: { 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/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/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/context-window-details.tsx b/packages/app/src/components/context-window-details.tsx new file mode 100644 index 0000000000..1da9da4c06 --- /dev/null +++ b/packages/app/src/components/context-window-details.tsx @@ -0,0 +1,82 @@ +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; + /** Whether the usage cards include a Refresh button. */ + refreshable: boolean; + /** Keep a long report list inside the floating tooltip's viewport. */ + scrollable?: 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, + scrollable, +}: 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 50016ae668..0b58db8d14 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,17 +1,22 @@ -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 { ContextWindowUsageTooltip } from "@/usage/context-window-section"; -import { formatContextPercentage, 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"; +import { formatContextPercentage } from "./context-window-meter.utils"; interface ContextWindowMeterProps { + serverId: string; + agentId: string; maxTokens: number | null; usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - serverId?: string; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -93,23 +98,37 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { } export function ContextWindowMeter({ + serverId, + agentId, maxTokens, usedTokens, totalCostUsd, showPercentage = false, - serverId, pending = false, glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t, i18n } = 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); - // No context telemetry yet: reserve the meter footprint while it is pending. + // No usage yet: reserve the footprint with a track-only ring while a session is + // active so the real ring fades in without shifting siblings. Render nothing when + // no usage is expected. if (percentage === null || maxTokens === null || usedTokens === null) { - if (!pending) return null; + if (!pending) { + return null; + } return ( + + + + ); + const percentageLabel = showPercentage ? ( + + {formatContextPercentage(roundedPercentage, i18n.resolvedLanguage)} + + ) : null; + const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage }); + + if (isCompact) { + return ( + <> + + {ring} + {percentageLabel} + + + + + + ); + } + return ( @@ -148,65 +234,31 @@ export function ContextWindowMeter({ style={containerStyle} testID="context-window-meter" accessibilityRole="button" - accessibilityLabel={t("contextWindow.accessibility", { - percentage: roundedPercentage, - })} + accessibilityLabel={accessibilityLabel} > - - - - - {showPercentage ? ( - - {formatContextPercentage(roundedPercentage, i18n.resolvedLanguage)} - - ) : 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} - {serverId ? : null} - + + ); @@ -239,23 +291,5 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, - tooltipContent: { - gap: theme.spacing[1.5], - minWidth: 200, - maxWidth: 280, - }, - 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/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/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; 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/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/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/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 84512df23e..d430f9d4df 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -614,6 +614,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} onPointerEnter={ctx.interactive ? handleContentEnter : undefined} @@ -651,6 +652,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} onFocus={handleContentEnter} @@ -669,6 +671,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 63098a50ec..3ea7060deb 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, @@ -279,6 +280,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) { } function renderContextWindowMeter( + serverId: string, + agentId: string, contextWindowMaxTokens: number | null, contextWindowUsedTokens: number | null, totalCostUsd: number | null, @@ -293,6 +296,8 @@ function renderContextWindowMeter( } return ( renderContextWindowMeter( + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, @@ -2097,6 +2105,8 @@ function ComposerContentImpl({ contextWindowMeterGlyphSize, ), [ + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, @@ -2390,7 +2400,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( () => 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/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/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} > - + 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/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/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/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/styles/theme.ts b/packages/app/src/styles/theme.ts index f58075b3a1..d5c1678749 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -595,8 +595,11 @@ export const ICON_SIZE = { sm: 14, md: 16, lg: 20, + xl: 24, } 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/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 10a2b0b632..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 @@ -73,11 +73,14 @@ function settleMountRefits(): Promise { return new Promise((resolve) => setTimeout(resolve, 2_600)); } -function createTerminalHost(input: { +interface CreateTerminalHostInput { width: number; height: number; scrollback?: number; -}): MountedTerminal { + isMac?: boolean; +} + +function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const root = document.createElement("div"); root.style.width = `${input.width}px`; root.style.height = `${input.height}px`; @@ -97,7 +100,9 @@ function createTerminalHost(input: { 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) => { @@ -176,30 +181,37 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } -function dispatchTerminalKey(input: { +interface DispatchTerminalKeyInput { host: HTMLElement; key: string; + keyCode?: number; shiftKey?: boolean; 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"); } 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); } /** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ @@ -514,6 +526,78 @@ 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, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + 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.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, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + 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, isMac: false }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + 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.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 4b7afb74c2..cc4462ff7b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,11 +15,13 @@ import { } from "@getpaseo/protocol/terminal-input-mode"; import { type PendingTerminalModifiers, + hasPendingTerminalModifiers, isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; @@ -299,7 +301,12 @@ export class TerminalEmulatorRuntime { attachKeyEventHandler( terminal: Pick< Terminal, - "attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste" + | "attachCustomKeyEventHandler" + | "hasSelection" + | "getSelection" + | "paste" + | "input" + | "scrollToBottom" >, ): void { terminal.attachCustomKeyEventHandler((event) => { @@ -339,6 +346,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; @@ -371,7 +382,7 @@ export class TerminalEmulatorRuntime { ...modifiers, }); - if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { + if (hasPendingTerminalModifiers(this.pendingModifiers)) { this.callbacks.onPendingModifiersConsumed?.(); } @@ -426,6 +437,26 @@ export class TerminalEmulatorRuntime { return this.inputModeTracker.getState(); } + private maybeSendMacEditingShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + if (!this.options.isMac || 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..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/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx new file mode 100644 index 0000000000..90cb6577bf --- /dev/null +++ b/packages/app/src/usage/agent-usage.tsx @@ -0,0 +1,94 @@ +import { type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { FloatingScrollView } from "@/components/ui/floating"; +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, + scrollable = false, +}: { + serverId: string; + agentId: string; + /** Whether each card has a Refresh button. */ + refreshable: boolean; + /** Limit long report lists in a floating surface. */ + scrollable?: boolean; +}) { + const view = useAgentUsage(serverId, agentId); + if (view.kind === "none") return null; + + let content: ReactNode; + if (view.kind === "ready") { + const cards = ( + + ); + content = scrollable ? ( + + {cards} + + ) : ( + cards + ); + } else { + content = ( + + {view.kind === "loading" ? usageCopy.loading : view.message} + + ); + } + + return ( + <> + + {content} + + ); +} + +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 }, + reports: { alignSelf: "stretch", gap: theme.spacing[3] }, + reportScroll: { alignSelf: "stretch", maxHeight: 240 }, +})); 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 0ec3976102..4971f6c2ee 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -28,6 +28,7 @@ function highlightStyle(pinned: boolean, hovered: boolean) { export function UsageWindowBar({ window, displayAs, + pinnable, pinned, onTogglePin, pinLabel, @@ -35,6 +36,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". */ @@ -55,6 +58,24 @@ export function UsageWindowBar({ const value = shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + const content = { + label: formatProviderUsageLabel(window.id, 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. @@ -70,14 +91,9 @@ export function UsageWindowBar({ > {({ hovered }: { hovered?: boolean }) => ( )} @@ -92,6 +108,7 @@ function WindowRowContent({ isAtRisk, percent, tone, + pinnable, pinned, }: { highlight: StyleProp; @@ -101,6 +118,7 @@ function WindowRowContent({ isAtRisk: boolean; percent: number; tone: UsageTone; + pinnable: boolean; pinned: boolean; }) { return ( @@ -121,7 +139,7 @@ function WindowRowContent({ - + {pinnable ? : null} ); 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 }) ); } 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..09ba53c182 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -135,6 +135,51 @@ 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: MacTerminalEditingShortcutInput, +): 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; 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/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/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", diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index 9de3cf6bb6..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); @@ -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]!; @@ -472,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], @@ -491,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/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 { 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/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index ae3541eeb6..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( "", - "Host directory, Git or npm source, optionally followed by :plugin/path", + "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/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/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/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", 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/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/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/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 a4f883604e..0214502263 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, @@ -1777,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(), @@ -6278,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({ @@ -6968,6 +6984,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsUpdateMessageSchema, UsageListReportsResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, 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/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); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2f9cd425ae..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 () => { @@ -11417,3 +11437,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 aebede5fa1..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; @@ -1157,6 +1160,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; @@ -3730,6 +3738,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) { @@ -3904,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 }, @@ -3916,7 +3942,7 @@ export class AgentManager { } try { - agent.currentModeId = await agent.session.getCurrentMode(); + this.applyObservedMode(agent, await agent.session.getCurrentMode()); } catch { agent.currentModeId = null; } @@ -4296,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 }; @@ -4312,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/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/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/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/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 { diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index a767dc9c2a..630bdbf0a6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -314,6 +314,127 @@ describe("background Claude subagents", () => { }); }); + test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => { + // Claude Code answers a background Agent call at once ("Agent launched") and only then streams + // the child's frames, so they arrive after the parent's tool call has its result. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const rows: AgentTimelineRow[] = events + .filter((event) => event.type === "timeline") + .map((event, index) => ({ + seq: index + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(), + item: event.item, + })); + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }); + test.each([ { source: "system task protocol", diff --git a/packages/server/src/server/agent/providers/claude/agent.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.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 e5153e2102..10048356b1 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. */ @@ -2090,7 +2092,11 @@ class ClaudeAgentSession implements AgentSession { // identity and status from frames for them. Detecting the capability beats comparing version // strings: it reacts to what this session actually does. isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, + // The parent's tool call owns its card once its result arrives. A background child streams + // its frames after Claude has already answered the call, and re-emitting the card from them + // would replace the settled card with an unlabeled, running one. needsSyntheticParentToolCard: (toolUseId) => + this.toolUseCache.has(toolUseId) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), }); private persistedHistory: PersistedTimelineEntry[] = []; @@ -2128,6 +2134,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 +2429,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); @@ -3241,10 +3248,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 }; } @@ -3255,7 +3270,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 +3296,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 +5089,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/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) { 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..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 }> = []; @@ -958,6 +1003,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..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 } : {}), @@ -723,6 +722,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; @@ -2360,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, @@ -2387,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/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/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/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..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; } @@ -115,6 +130,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 }> = []; @@ -173,7 +189,12 @@ 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, thinkingLevel: "medium", @@ -331,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/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/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]; 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..8035d59830 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({ @@ -1522,8 +1528,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1533,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) => { @@ -1709,8 +1794,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -2570,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({ @@ -2588,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 6b5de0314e..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, @@ -1202,6 +1201,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; @@ -2577,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, @@ -2595,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/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..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) { @@ -93,6 +102,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; @@ -144,7 +154,11 @@ 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, thinkingLevel: "medium", @@ -265,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"); } 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 659ac54d02..e63ce89fb3 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,9 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; + pluginRegistryEnabled?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -615,8 +619,16 @@ 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), + 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, { + enabled: config.pluginRegistryEnabled ?? false, + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); @@ -1160,7 +1172,6 @@ export async function createPaseoDaemon( emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, serviceProxy, scriptRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), @@ -1411,9 +1422,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), @@ -1807,6 +1817,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/config-environment.ts b/packages/server/src/server/config-environment.ts index 92bf9183ee..2442ee5dc5 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -29,6 +29,8 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_COUNT", "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 80045a4454..02a315c9a5 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,27 @@ 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); + 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 a683ec69fb..61481ce1f9 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -626,6 +626,12 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, 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/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/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 40c4150907..f924a88ebc 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,8 @@ export const PersistedConfigSchema = z .optional(), 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/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 = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { emit?: PluginLifecycle["emit"]; + drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; @@ -55,6 +60,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + usageAgents?: AgentUsageLookup; settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; @@ -95,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, { @@ -119,6 +130,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); @@ -144,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); } @@ -568,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/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/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 9d9bad15f3..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-")); @@ -48,24 +67,13 @@ 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); - 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", - "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", @@ -73,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 () => { @@ -275,3 +278,204 @@ describe("managed Git plugin sources", () => { } }, 30_000); }); + +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 }); + 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, { + enabled: true, + 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 = { enabled: true, 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..8ee3b2b6f6 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,16 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { + 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"); + 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 { @@ -150,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); @@ -163,7 +188,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 +227,18 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.kind === "npm") { + 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, + 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 +247,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 +304,14 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; + 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)) + 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 +337,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) @@ -394,7 +436,10 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } -function normalizeGitSource(source: string): string { +/** 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)) { @@ -405,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) { + if (github && expandsShorthand) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -578,3 +623,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..179bdc5da7 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,56 @@ +import { + PublishedPluginDetailSchema, + type PluginRegistries, + type PluginRegistryIdentity, +} from "@getpaseo/protocol/plugin-registry"; +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; +} +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/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 a26e7cbfea..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, @@ -295,6 +296,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; @@ -401,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( @@ -515,22 +522,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/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.ts b/packages/server/src/server/session.ts index 1307cf808f..d57333da05 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, @@ -5210,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(); @@ -5237,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)) @@ -5251,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)) @@ -5258,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 { @@ -7247,7 +7255,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, @@ -7294,7 +7301,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, @@ -7570,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; } @@ -7583,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", 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/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, 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", ); 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; } 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/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index c7d4126af1..68a2693562 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,65 @@ 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); + }); + + // 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 35e8f5154b..049b1011ac 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,37 @@ 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. 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 sharedFilesystemCall(`readdir:${directory}`, () => + fs.readdir(directory, { withFileTypes: true }), + ); +} + +function realpath(target: string): Promise { + return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target)); +} + +function stat(target: string): Promise { + return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target)); +} + export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", @@ -655,7 +687,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) diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..d2066bca61 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,306 @@ +import { expect, test, type Page } from "playwright/test"; +import { CATEGORIES } from "../src/plugins/categories"; + +async function openPlugins(page: Page) { + 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 ({ + 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); + + 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 page.getByRole("link", { name: /Fresh Worktrees/ }).click(); + await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); + 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 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", + ); + + 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("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(/\/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\/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(); +}); + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, category, author, and complete plugin HTML with page metadata", async ({ + page, + }) => { + await openPlugins(page); + 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(); + 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("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(); + 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"); + 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("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"); + 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"); + 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( + "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(); +}); + +// External deployments own their registry contents; these assertions use the local fixture. +test.describe("registry fixture layout", () => { + test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + + test("lists the nine categories in order with counts, and the newest plugins first", async ({ + page, + }) => { + await openPlugins(page); + 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/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/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/playwright.config.ts b/packages/website/playwright.config.ts index 011a34e6ad..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 dev -- --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/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/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 ) : ( <> diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index ba092410da..62daa6ebd0 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,13 +1,19 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; 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 (