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/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml index e2f1fafb3c..c30eea7da4 100644 --- a/.github/workflows/desktop-packages.yml +++ b/.github/workflows/desktop-packages.yml @@ -9,6 +9,7 @@ on: branches: [main] paths: - "packages/desktop/**" + - "plugins/**" - "nix/**" - ".github/workflows/desktop-packages.yml" diff --git a/CHANGELOG.md b/CHANGELOG.md index 548a24aaed..186dad677e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,128 @@ # Changelog +## 0.11.0-beta.3 - 2026-10-02 + +### Added + +- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876)) + +### Changed + +- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876)) + +### Fixed + +- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876)) + +## 0.11.0-beta.2 - 2026-10-01 + +### Added + +- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) + +### Changed + +- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844)) + +### Fixed + +- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) + +## 0.11.0-beta.1 - 2026-10-01 + +### Added + +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) +- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) +- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) +- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) +- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) +- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708)) +- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) +- Added provider option defaults in `config.json` under `agents.providers..options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780)) +- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) +- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) + +### Changed + +- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750)) +- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340)) + +### Fixed + +- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) +- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) +- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) +- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) +- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) +- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) +- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) +- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) +- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) +- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) +- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684)) +- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686)) +- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758)) +- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774)) +- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804)) +- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) +- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) +- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) +- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) +- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) +- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) +- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602)) +- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772)) +- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) +- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815)) + +## 0.10.3 - 2026-10-02 + +### Added + +- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) + +## 0.10.2 - 2026-09-30 + +### Fixed + +- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674)) +- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger)) +- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679)) +- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696)) +- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609)) + ## 0.10.1 - 2026-09-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b1f8a294ed..e1ac653338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine. -**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi. +**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code. ## Repository map diff --git a/README.md b/README.md index 120672746b..aa0a841e7e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-

One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.

+

One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.

Paseo app screenshot @@ -42,7 +42,7 @@ Run agents in parallel on your own machines. Ship from your phone or your desk. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. -- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job. +- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. @@ -68,6 +68,8 @@ You need at least one agent CLI installed and configured with your credentials: - [GitHub Copilot](https://github.com/features/copilot/cli/) - [OpenCode](https://github.com/anomalyco/opencode) - [Pi](https://pi.dev) +- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity) +- [Muse Code](https://paseo.sh/docs/muse-code) ### Desktop app (recommended) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 1f872b9d1b..efc5fdf24f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds: The limit applies independently to every provider refresh and covers availability plus the entire catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent. +## Provider options + +Set provider defaults in `agents.providers..options`. At agent creation, use +`providerOptions` on the wire or `config.options` in the SDK. Both are opaque +`Record` values: the provider owns validation and application. +Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error. +Providers without option handling ignore the record. + +The daemon deep-merges the agent's options over the provider defaults at each +session launch or resume. Plain objects merge recursively; arrays, scalars, and +`null` replace the base value. Only the agent's own options are persisted, so edits +to provider defaults apply on the next launch or resume. A profile inherits its +base provider's options unless it sets its own options record. + +```json +{ + "agents": { + "providers": { + "codex": { + "options": { "sandbox_mode": "read-only", "approval_policy": "never" } + } + } + } +} +``` + +See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for +per-agent examples and provider-specific keys. + --- ## Table of Contents @@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` "XDG_CONFIG_HOME": "~/.config/omp-work", "XDG_STATE_HOME": "~/.local/state/omp-work" }, - "params": { + "options": { "sessionDir": "~/.local/state/omp-work/omp/agent/sessions", "rpcTimeoutMs": 60000, "smolModel": "openai/gpt-5-mini", @@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` } ``` -`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines. +`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines. For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory: @@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere "extends": "pi", "label": "My Pi Fork", "command": ["my-pi-fork"], - "params": { + "options": { "sessionDir": "~/.my-pi-fork/sessions", "rpcTimeoutMs": 60000 } @@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere } ``` -This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. +This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. --- @@ -484,7 +513,7 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) -Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: +Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`: ```json { @@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog "extends": "acp", "label": "My Agent", "command": ["my-agent", "acp"], - "params": { + "options": { "supportsMcpServers": false } } @@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog ACP agents execute filesystem operations in their own environment by default, while terminal operations run through Paseo on the host. To customize which -operations Paseo handles, configure client capabilities in provider params: +operations Paseo handles, configure client capabilities in provider options: ```json { @@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params: "extends": "acp", "label": "Container Agent", "command": ["container-agent", "acp"], - "params": { + "options": { "clientCapabilities": { "fs": { "readTextFile": false, @@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields: | `description` | `string` | No | Short description shown in the UI | | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | -| `params` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | +| `options` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | | `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | diff --git a/docs/data-model.md b/docs/data-model.md index 8c63e67d0b..25a214b11c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -182,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId` Single file, validated with `PersistedConfigSchema`. `agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means -`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed -skill directories and keeps config plus filesystem convergence behind one serialized owner. +`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared +`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind +one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from +Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files. `paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution, classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe diff --git a/docs/design.md b/docs/design.md index e0904d917e..e21270c85d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -217,7 +217,7 @@ Changing state must not move the layout. A row that grows when its badge arrives The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`). -Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the ``. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation. +Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the ``. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place. Kebab menus (`` with `` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`). 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/floating-panels.md b/docs/floating-panels.md index b0c16b3e4a..9c4e793fb8 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This +includes native centered dialogs: separate React Native Modals cannot present a root-owned +sibling dialog over an existing iOS presenter. + +The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary +but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, +and restoration then silently do nothing. Callers may use callback refs on the wrapper, which +forwards commands to the current Gorhom handle. + +Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, +Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated +pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop +keeps its press target until portal teardown and asks the provider to dismiss the top modal. +Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native +retains Gorhom's gesture backdrop and the same push/replace presentation semantics. + ## Gotcha 7 — A sheet cannot read context from its call site React cannot copy contexts reflectively, so the only way across the teleport in diff --git a/docs/glossary.md b/docs/glossary.md index 4e0111475c..390acefa19 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. -- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. +- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). @@ -33,7 +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 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/menus.md b/docs/menus.md index 3a6829d738..6791d28e71 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -191,6 +191,8 @@ its own. [floating-panels.md](floating-panels.md). - **One overlay per menu.** Submenus render inside their parent's layer and paint no second backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. +- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only + request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes 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 370d3a2bdc..1093381c61 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -88,34 +88,64 @@ never deletes it. The global `pluginsEnabled` switch remains available. ## Built-in plugins -Built-in plugins live in `plugins//` and ship with the daemon. Add a directory and one ID to -`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build -copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run -in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin -list. Their client bundles appear in the plugin catalog. Editing one in development requires a -daemon restart. Directory, Git, and npm installs cannot use a built-in ID. +Built-in plugins ship from `plugins//`, with `paseo-plugin.json`, `index.server.ts`, +`server/`, and optional client entry and icon. Add the plugin ID to `builtinPlugins` in +`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI +checks cover that registry. Unlisted directories do not load. + +Desktop packaging ships the entire built-in plugin directory as an external resource, +including declarations. The external esbuild compiler cannot read Electron's `app.asar` +filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation. +Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins: +outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch +one because it resolves through the repository's `node_modules`. +The packaged-app smoke check requires every listed built-in to start without relying on +account credentials. + +Built-ins run in process and remain active independently of `pluginsEnabled`. They are +absent from the installed plugin list and source configuration; their client bundles +appear in the plugin catalog. Editing one in development requires a daemon restart. +Directory, Git, and npm installs cannot use a built-in ID. + +Provider plugins use separate installation and provider IDs. `muse-provider` registers the +selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports +availability and a diagnostic after the daemon resolves the executable. Configure command, +environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep +the bundled integration; an entry with `extends` shadows it with a custom provider. See +[provider contributions](#contribute-a-provider) for the contract and +[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations. + +## Install from a registry + +`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs +keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref +selection is unavailable for registry installs; install an explicit source to select your own. + +Use `host/owner/slug` for a registry at `https://host`, or set +`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). +Private registry credentials live in daemon config under +`pluginRegistries: { "host": { "authorization": "Bearer token" } }`. +Restart your daemon after changing these startup settings. Credentials go only to the registry, +never artifact hosts or redirects. Git/npm use their own host authentication. + +The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md) +owns static hosting, record shapes, pins, and advisory install counts. ## Install a Git source -GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing -directory always wins over shorthand resolution. +GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory +still wins over source resolution. ```bash -paseo plugin add owner/repository -paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo:plugins/review -paseo plugin add owner/repository --ref main -paseo plugin ls -paseo plugin update review -paseo plugin update --all +paseo plugin install github:owner/repository +paseo plugin install https://gitlab.com/group/repository.git +paseo plugin install github:owner/monorepo:plugins/review +paseo plugin install github:owner/repository --ref main ``` -Append `:relative/path` to the source when the plugin lives below the repository root. - -`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's -current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote. -Removing a Git source deletes Paseo's managed checkout. +Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial +branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD +and ask for approval. `ls` reports the installed commit without contacting the remote. ## Managed source ownership @@ -259,12 +289,23 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin/client"; +import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { Greeting } from "./client/greeting"; +function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "main" })} + /> + ); +} + export default function contribute(client: PluginClientContext) { - client.addSurface("main", Greeting); - client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" }); + client.addScreen({ id: "main", title: "Greeting", Component: Greeting }); + client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem }); return () => {}; } ``` @@ -277,15 +318,33 @@ errors are logged and do not interrupt host teardown. Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts, and render error boundary. The contributed component owns the complete body below the header. +The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in +`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/`) shows the +item's title and icon instead, as before screens had titles; the `addSurface` alias titles its +screen with the id. A `/sidebar/` route with no legacy item of that id opens the screen with +that id, so links saved before a plugin migrated keep working. + +Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so +reload, history, links, and the host switcher keep them without a store. Expo Router merges the +route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param +keys are stored under a `param.` prefix; plugins never see it and may use any key. +`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the +params. RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query` for request state and caching; Paseo gives each plugin installation its own query client. `usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects, -workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the -selected host's existing connection; switching the screen's host changes both `usePaseo()` and -`useRpc()` to that host. An offline selected host fails there and never falls through to another +workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. + +`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in +`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as +`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable, +reload, removal, host removal) disposes the client, which ends the rest; nothing is created or +disposed per mounted surface. See the +[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host +changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation. @@ -321,7 +380,7 @@ existing agent-context instances, but it cannot create an agent panel without an Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations. They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation belongs to the app; plugins do not receive Expo Router or workspace-layout store access. -See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items) +See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items) and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers) for the author-facing contract. @@ -352,9 +411,38 @@ export default function contribute(server: PluginServerContext) { } ``` +Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies +`agents.providers..command` and `env`, resolves the executable against the effective PATH, and +removes parent-session and daemon-control environment variables. `connect({ launch })` receives +`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized +environment. Spawn with those values; do not merge the plugin process's environment back in. +The launch travels as data through the same provider protocol for built-in and subprocess plugins. +Without a declared command, existing providers keep their connection behavior and receive no launch. + +Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using +that effective launch. Return `{ available: true }` or +`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability +before calling status; it exposes your diagnostic through the normal provider diagnostic command. +A command-backed provider without status is available when its executable resolves. Providers with +neither command nor status retain connection-based availability. + +A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label, +description, and model configuration through the normal provider registry. `enabled: false` disables +selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin, +and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays +inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup +settles (including disabled plugins), then on each installed registry generation. +Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores +remain valid for plugin registration but cannot be used as config provider IDs. + +Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next +connection receives the new launch without restarting the daemon. Running sessions keep their +current launch until refreshed, as native providers do. + Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue probes. The callback runs in the plugin process before discovery and receives the actual global or -workspace target. Return a key covering effective configuration and execution environment, or +workspace target plus the resolved `launch` when a command is declared. Return a key covering +effective configuration and execution environment, or `undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract). @@ -362,15 +450,22 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. Route messages, structured commands, steering, and command side effects through `session.prompt`. Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in -the opaque `providerOptions` config object. +the opaque `ProviderSessionConfig.providerOptions` object on `session.open`. +It contains the provider defaults and per-agent overrides merged by the daemon. +Validate and apply it inside the provider; core does not know your option shape. +See [provider options](custom-providers.md#provider-options) for configuration and +merge semantics. Agent refresh closes the current provider session and opens it again with current configuration and -persistence. Providers re-read credentials, environment, global configuration, and MCP servers on -`session.open`; there is no provider reload input. +persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the +daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is +no provider reload input. For an ACP command, register `runAcpProvider({ id, label, command })` from `@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the -whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct` +whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying +only the supplied per-session env for sessions. The direct and ACP examples live in +`plugin-examples/provider-direct` and `plugin-examples/provider-acp-transformer`. Provider-emitted plugin timeline items use the same renderer registration as transformed and @@ -388,10 +483,45 @@ 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`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `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. +## Contribute sidebar items + +Sidebar header and footer items are plugin components, not descriptors. The +[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host +lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves +one section's order from built-ins, plugin groups, and the section's preference +(`sidebarNavItems` or `sidebarFooterItems`). + +- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array + of rows lays out like separate items while Settings keeps one entry for the block. Footer items + are rows between Add project and the footer's icon row. The icon row (Hosts, + Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon + button. +- `openPopover` opens through the same `PluginPopoverSurface` as header buttons + (`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves + the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the + pressed child claims the touch on native and web, and returns false. Keyboard activation skips + the responder system, so the row's own press anchors too. Until a press, the first mounted row + holds the anchor. An item that renders no kit component + anchors to its section container. +- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so + the line runs edge to edge in both sections. +- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest + buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the + row; a button inside it wins its own press because only the innermost pressable responds. +- A new-API item renders from the current route's host, else the host remembered under + `pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen` + set. One key per plugin: switching host on any of its screens moves all its items. +- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged + `COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and + groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in + the sidebar and in Settings > Sidebar, opening `/plugin//sidebar/` on the host it + remembers under its own key. It shares header ids and the `plugin::` settings key + with new-API items. + ## Contribute buttons Header buttons and composer pills share the client-only descriptor and registration lifecycle in @@ -461,7 +591,7 @@ be rendered intact. The daemon advertises this RPC through `addSlashCommand` registers an agent- or workspace-context command in the composer. The callback runs in the app, receives the trimmed text after the command name as `args`, and receives -the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching +the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching Command Center callback. ```ts diff --git a/docs/providers.md b/docs/providers.md index 6ea928f3c9..47c39fd1a8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,52 +1,38 @@ # Adding a New Provider to Paseo -This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both. +Add new providers through the plugin SDK. The core adapter patterns below describe the existing +server integrations. + +## Plugin providers + +Keep a bundled provider in `plugins//` and register it through +`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK +import rules belong to [plugins.md](plugins.md#built-in-plugins); the +[public provider guide](../public-docs/plugins/providers.md) covers the provider contract. + +The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns +executable resolution and applies `agents.providers..command` and `env` before +connecting. Register the provider's icon with the plugin rather than adding it to the app's +provider icon map. You do not need a core manifest entry or provider factory. + +| Provider | Transport | Setup and limitations | +| ----------- | ------------------------------------------ | ---------------------------------------------------------------- | +| Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) | +| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) | ## Provider-native session options -`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The -names and nesting are the provider's native contract; options are not portable between providers. -Paseo validates the object with the selected provider's strict schema before constructing a session. -Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model, -prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as -top-level provider options. +The provider owns validation and application of the opaque record in +`AgentSessionConfig.providerOptions`. The registry supplies the effective options +at session startup. See [provider configuration](custom-providers.md#provider-options) +for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) +for native keys and examples. -This Paseo version accepts these keys: +Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail +closed for Hub unattended execution until it can approve one exact injected MCP +server and tool identity without approving native tools. -- **Codex:** `approval_policy`, `sandbox_mode`, - `sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`, - `web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may - contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`, - `allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`, - `dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the - [Codex configuration reference](https://developers.openai.com/codex/config-reference). -- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and - `settings`. `providerOptions.extraArgs` passes the SDK's documented - [`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map - unchanged: keys omit the leading `--`, string values supply an argument value, and `null` - supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }` - passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`. - Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see - [plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal; - shell expressions such as `$(command)` are not evaluated. The accepted sandbox - fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands, - filesystem read/write rules, network domain/socket/local-binding rules, weaker nested - sandboxing, ignored violations, and the ripgrep command. `settings` accepts native - `permissions.{allow,ask,deny}` and sandbox settings. See the - [Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript) - and [Claude settings reference](https://code.claude.com/docs/en/settings). -- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule - object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, - `external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`, - `repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the - [OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are - application policy, not an OS sandbox. - -Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider -must fail closed for Hub unattended execution until it can approve one exact injected MCP server -and tool identity without approving native tools. - -## Two Integration Patterns +## Core adapter patterns ### ACP (Agent Client Protocol) -- recommended @@ -69,7 +55,7 @@ model; it does not override the model list returned by a resolver. Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. -Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code. @@ -85,7 +71,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead. -Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config. +Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence. + +The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config. Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines. @@ -199,15 +187,10 @@ promise for completion: equal results, including equal discovery timestamps, emi ## Usage sources -Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`. - -Create a built-in source under `plugins/-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources). - -`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`. - -### Credentials are read only - -A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model. +See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the +contract, account and window identity, provider-derived period names, login fallback, and +read-only credential rules. Usage adapters own the interpretation of provider fields; the app +renders their names and resolves pins without provider-specific duration guesses. --- @@ -295,7 +278,7 @@ export class CopilotACPAgentClient extends ACPAgentClient { ### 2. Add to the provider manifest -In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry. +In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry. First, define the modes with visual metadata: 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 8706078489..fa431c78ff 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c= +sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc= diff --git a/package-lock.json b/package-lock.json index c396338f9b..01d3bd7f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.10.0", + "version": "0.11.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.10.0", + "version": "0.11.0-beta.3", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -6330,6 +6330,15 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@getpaseo/app": { "resolved": "packages/app", "link": true @@ -26261,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", @@ -36415,7 +36413,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36480,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", @@ -37335,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", @@ -37481,12 +37496,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", - "@getpaseo/server": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/server": "0.11.0-beta.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37733,10 +37748,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { - "@getpaseo/protocol": "0.10.0", - "@getpaseo/relay": "0.10.0", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/relay": "0.11.0-beta.3", "zod": "^4.4.3" }, "devDependencies": { @@ -37747,7 +37762,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.10.0", + "version": "0.11.0-beta.3", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -37991,7 +38006,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.10.0", + "version": "0.11.0-beta.3", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38887,7 +38902,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38916,14 +38931,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38933,8 +38948,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38956,7 +38971,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38983,7 +38998,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -39201,16 +39216,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.10.0", - "@getpaseo/highlight": "0.10.0", - "@getpaseo/plugin": "0.10.0", - "@getpaseo/protocol": "0.10.0", - "@getpaseo/relay": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/highlight": "0.11.0-beta.3", + "@getpaseo/plugin": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/relay": "0.11.0-beta.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -40131,10 +40146,11 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.10.0", + "version": "0.11.0-beta.3", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", + "@fontsource-variable/geist": "^5.3.0", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "framer-motion": "^12.35.2", @@ -40149,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", @@ -41736,7 +41753,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.0.0", + "version": "0.11.0-beta.3", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 948cd29b27..f27be7301c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.10.0", + "version": "0.11.0-beta.3", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index e7f87ed103..75cf7ff0c9 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => { await gate.waitForHeldServerMessage("send_agent_message_response"); await expect(page.getByText("hello", { exact: true })).toHaveCount(1); await expect(page.getByText(/^Worked for/)).toHaveCount(0); + await expectAgentReadyToInterrupt(page); gate.releaseHeldServerMessage("send_agent_message_response"); await expect(page.getByText("hello", { exact: true })).toHaveCount(1); } finally { 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/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 967462f05b..89ee10af22 100644 --- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -84,6 +84,18 @@ async function openModelSelector(page: Page) { await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 }); } +async function dismissStackedModelPickerWithBackdrop(page: Page) { + await page + .getByTestId("agent-controls-settings-list") + .getByRole("button", { name: /Select model/ }) + .click(); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible(); + await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible(); + await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible(); + await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); +} + async function openAndCloseTabSwitcherTwice(page: Page) { await openTabSwitcher(page); await closeBottomSheetWithBackdrop(page); @@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => { await openModelSelector(page); const sheet = page.getByTestId("agent-controls-model-sheet"); + await dismissStackedModelPickerWithBackdrop(page); + await page.getByTestId("model-search-all-input").click(); const model = page.getByRole("button", { name: /^Ten second stream/ }); await expect(model).toBeVisible({ 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/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts new file mode 100644 index 0000000000..654f15207d --- /dev/null +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -0,0 +1,122 @@ +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; + +// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only. +// The real daemon discovers both through the same Codex model/list boundary. +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + providers: { + codex: { + enabled: true, + command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")], + }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }, + }, + }, +}); + +async function openSpeedSelector(page: Page): Promise { + const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); + const features = page.getByRole("button", { name: "Open agent features", exact: true }); + const modelSettings = page.getByRole("button", { name: /^Select model \(/ }); + await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({ + timeout: 30_000, + }); + if (!(await speed.isVisible())) { + if (await features.isVisible()) await features.click(); + else await modelSettings.click(); + } + await expect(speed).toBeVisible({ timeout: 30_000 }); + await speed.click(); + await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible(); +} + +async function selectSpeed(page: Page, label: string): Promise { + await page.getByRole("button", { name: label, exact: true }).last().click(); + const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true }); + const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true }); + await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible(); + if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText(""); + else await expect(sheetTrigger).toContainText(label); +} + +async function expectSpeedChoices(page: Page, choices: string[]): Promise { + for (const choice of ["Normal", "Fast", "Ultrafast"]) { + const option = page.getByText(choice, { exact: true }).last(); + if (choices.includes(choice)) { + await expect(option).toBeVisible(); + await expect(option).toBeInViewport({ ratio: 1 }); + } else await expect(option).not.toBeVisible(); + } +} + +async function switchAgentModel(agentId: string, modelId: string): Promise { + const client = await connectDaemonClient< + Pick + >({ + clientIdPrefix: "codex-speed-model", + }); + try { + await client.setAgentModel(agentId, modelId); + } finally { + await client.close(); + } +} + +for (const viewport of [ + { name: "desktop", width: 1280, height: 850 }, + { name: "compact", width: 390, height: 844 }, +]) { + test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => { + test.setTimeout(180_000); + await page.setViewportSize(viewport); + const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" }); + try { + const agent = await workspace.client.createAgent({ + provider: "codex", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Speed settings", + model: "gpt-6.1-sol", + modeId: "full-access", + initialPrompt: "Get ready to work on this project.", + }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + if (viewport.name === "desktop") { + const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true }); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await expect(trigger).toHaveText(""); + await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); + } + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); + await selectSpeed(page, "Ultrafast"); + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); + await page.screenshot({ + path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), + }); + await selectSpeed(page, "Normal"); + if (viewport.name === "desktop") { + await switchAgentModel(agent.id, "gpt-6-sol"); + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast"]); + await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") }); + } + } finally { + await workspace.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index c7868ac94e..7905d2c1cd 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,7 +1,40 @@ -import type { Locator } from "@playwright/test"; -import { expect, test } from "../support/fixtures"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test as base } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; +import { + type AgentUsageScript, + closeContextWindowSheet, + contextWindowSheet, + expectRefreshButtons, + expiredLogin, + gate, + hoverContextWindowMeter, + leaveContextWindowMeter, + type MockAgentSession, + onPersonalLogin, + onWorkLogin, + openAgent, + pressContextWindowMeter, + qaScreenshot, + refreshUsageCard, + reloadAgent, + scriptAgentUsage, + seedAgentWithContextWindow, + usageCard, +} from "../support/helpers/context-window"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item"; + +const test = base.extend<{ agent: MockAgentSession }>({ + agent: async ({ page: _page }, provide) => { + const agent = await seedAgentWithContextWindow(); + try { + await provide(agent); + } finally { + await agent.cleanup(); + } + }, +}); // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none. @@ -69,3 +102,184 @@ test.describe("context window meter", () => { } }); }); + +const DESKTOP = { width: 1440, height: 900 }; +const COMPACT = { width: 390, height: 844 }; +const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/; + +type OpenDetails = (page: Page) => Promise; +type Shot = (state: string) => Promise; + +/** Two reports on the agent's login stream in; the details show each card as it arrives. */ +async function expectCardsToStreamIn( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + const [claude, codex] = claudeAndCodexReports(); + const first = gate(); + const second = gate(); + usage.answerNext({ + stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)], + }); + const details = await openDetails(page); + await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible(); + await shot("loading"); + + first.open(); + await expect( + usageCard(details, "claude:work").getByText("work@example.com", { exact: true }), + ).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(usageCard(details, "codex:work")).toHaveCount(0); + await shot("streaming"); + + second.open(); + await expect( + usageCard(details, "codex:work").getByText("Session", { exact: true }), + ).toBeVisible(); + // Only the agent's login, never the host's default one, and no pins. + await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0); + await expectUnpinnableRows(details); + await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2); + return details; +} + +async function expectFailedRequestSentence( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.answerNext({ error: "Unknown agent" }); + await reloadAgent(page); + const details = await openDetails(page); + await expect( + details.getByText("Unable to load usage: Unknown agent", { exact: true }), + ).toBeVisible(); + await shot("error"); +} + +async function expectOnlyContextWindowWithoutUsage( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.stopReportingUsage(); + await reloadAgent(page); + const details = await openDetails(page); + await expect(details.getByText("25% used", { exact: true })).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0); + await shot("unsupported"); +} + +for (const theme of ["light", "dark"] as const) { + test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`); + + await test.step("reports stream in one card at a time, without Refresh", async () => { + const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + // The tooltip cannot be pressed, so its cards have no Refresh. + await expectRefreshButtons(tooltip, 0); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); + }); + + await test.step("after a resume under another login, only that login shows", async () => { + // Closed while the old login's report is still on its way. + await leaveContextWindowMeter(page); + const slowSource = gate(); + usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] }); + const closedEarly = await hoverContextWindowMeter(page); + await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible(); + await leaveContextWindowMeter(page); + + const resumed = gate(); + const finished = gate(); + usage.answerNext({ + stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], + }); + const tooltip = await hoverContextWindowMeter(page); + await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible(); + await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + + // The old login's report lands during the new request, before the new login's. + slowSource.open(); + resumed.open(); + await expect( + usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }), + ).toBeVisible(); + await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + finished.open(); + }); + + await test.step("a report with a problem shows it on the card", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + await reloadAgent(page); + const tooltip = await hoverContextWindowMeter(page); + await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible(); + await shot("problem"); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); + // One request per open that had usage; this one sends none. + expect(usage.agentRequests()).toHaveLength(5); + }); + }); + + test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({ + page, + agent, + }) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize(COMPACT); + await openAgent(page, agent); + const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`); + + await test.step("reports stream in one card at a time, each with Refresh", async () => { + const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); + await expectRefreshButtons(sheet, 2); + await shot("ready"); + expect(usage.agentRequests()).toEqual([agent.agentId]); + }); + + await test.step("Refresh replaces the card with the source's new report", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + const sheet = contextWindowSheet(page); + await refreshUsageCard(sheet, "Claude"); + await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedReports()).toEqual([["claude:work"]]); + await shot("problem"); + }); + + await test.step("Close dismisses the sheet", async () => { + await closeContextWindowSheet(page); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); + expect(usage.agentRequests()).toHaveLength(2); + }); + }); +} diff --git a/packages/app/e2e/browser/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 df332c8278..a798f8bf07 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -127,6 +127,36 @@ 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, }) => { @@ -310,7 +340,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/host-confirmation.spec.ts b/packages/app/e2e/browser/host-confirmation.spec.ts new file mode 100644 index 0000000000..6de8da14ce --- /dev/null +++ b/packages/app/e2e/browser/host-confirmation.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from "../support/fixtures"; +import { openSettings } from "../support/helpers/app"; +import { expectAppRoute } from "../support/helpers/route-assertions"; +import { + buildPairingLink, + cancelHostConfirmation, + connectToConfirmedHost, + expectHostConfirmationFor, + expectHostInHostPicker, + hostConfirmation, + openPairingLink, + saveLinkedHostBeforeLoad, + type LinkedHost, +} from "../support/helpers/host-confirmation"; + +// An unreachable relay: the link saves the host without connecting first. +const linkedHost: LinkedHost = { + serverId: "srv_link_confirmation", + relayEndpoint: "127.0.0.1:59998", + daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ", +}; + +test("a pairing link for a new host asks before saving it", async ({ page }) => { + const link = buildPairingLink(linkedHost); + + await test.step("Cancel saves nothing and stays on the page", async () => { + await openPairingLink(page, link); + await expectHostConfirmationFor(page, linkedHost); + await cancelHostConfirmation(page); + await expectAppRoute(page, "/settings/general"); + await expectHostInHostPicker(page, linkedHost.serverId, "not listed"); + }); + + await test.step("Connect saves the host and opens a project", async () => { + await openPairingLink(page, link); + await expectHostConfirmationFor(page, linkedHost); + await connectToConfirmedHost(page); + await expectAppRoute(page, "/open-project"); + await openSettings(page); + await expectHostInHostPicker(page, linkedHost.serverId, "listed"); + }); +}); + +test("a pairing link for a saved host connects without asking", async ({ page }) => { + await saveLinkedHostBeforeLoad(page, linkedHost); + + await openPairingLink(page, buildPairingLink(linkedHost)); + + await expectAppRoute(page, "/open-project", { timeout: 30_000 }); + await expect(hostConfirmation(page)).toHaveCount(0); +}); diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 592d31c330..788a6f93ec 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => { await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 }); const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await flow.revealMobileEntryPoint(); await flow.openGlobally(); const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`); @@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await test.step("the mobile sidebar exposes import in its footer", async () => { - await flow.revealMobileEntryPoint(); - await capture(page, testInfo, "01-mobile-sidebar-footer.png"); + await test.step("the new workspace screen offers import at the top", async () => { + await flow.revealNewWorkspaceEntryPoint(); + await capture(page, testInfo, "01-mobile-new-workspace-import.png"); }); await test.step("the host-wide sheet is newest first and fits its provider filter", async () => { - await flow.openGlobally(); + await flow.openFromNewWorkspace(); await flow.expectScope("Sessions on", false); await flow.expectRows({ first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"], 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 new file mode 100644 index 0000000000..900fb42db9 --- /dev/null +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -0,0 +1,483 @@ +import path from "node:path"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + BOTS_PLUGIN_ID, + COMPACT, + LEGACY_PLUGIN_ID, + SHOWCASE_PLUGIN_ID, + WIDE, + closeScreen, + expectRowActive, + footerItem, + headerRow, + installSidebarPlugins, + popoverBox, + runCommand, + visibleTestId, +} from "../support/helpers/plugin-sidebar-items"; +import { + leaveSettings, + openSidebarNavSettings, + seedSidebarFooterPreferences, + setFooterItemVisible, +} from "../support/helpers/sidebar-nav-settings"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; + +/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */ +async function qaScreenshot(page: Page, name: string, area?: Locator) { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + // Let popover, sheet and drawer animations settle so the image shows the final state. + await page.waitForTimeout(600); + // Expo's fast-refresh indicator sits over the footer's Hosts icon. + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + const file = path.join(directory, `${name}.png`); + if (area) await area.screenshot({ path: file }); + else await page.screenshot({ path: file }); +} + +/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */ +async function expectFooterRow(page: Page, row: Locator) { + // Polled: on compact the drawer is still sliding in when the row first shows. + await expect + .poll(async () => { + const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!; + const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!; + const box = (await row.boundingBox())!; + return { + aboveBottomLine: box.y + box.height <= addProject.y, + alignedLeft: Math.abs(box.x - addProject.x) < 1, + alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1, + }; + }) + .toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true }); +} + +function botRow(page: Page, rowId: string): Locator { + return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`); +} + +async function boxOf(locator: Locator) { + const result = await locator.boundingBox(); + expect(result).not.toBeNull(); + return result!; +} + +/** The open screen's header title; a pushed screen keeps the one below it mounted. */ +function screenTitle(page: Page): Locator { + return page.locator('[data-testid="plugin-surface-title"]:visible').last(); +} + +async function expectBotScreen(page: Page, botId: string) { + await expect(page).toHaveURL( + new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`), + ); + await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible(); + // The plugin titles the screen from its params: the bot's name. + await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2"); + await expectRowActive(botRow(page, "bot-1"), botId === "bot-1"); + await expectRowActive(botRow(page, "bot-2"), botId === "bot-2"); + await expectRowActive(botRow(page, "status"), false); +} + +/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */ +async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) { + const popover = await popoverBox(page, text); + const rowBox = await boxOf(row); + const otherBox = await boxOf(elsewhere); + expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width); + expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40); + expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20); +} + +/** Subscription ids the daemon confirms released, collected from the page's websocket. */ +function trackReleasedSubscriptions(page: Page): Set { + const released = new Set(); + page.on("websocket", (socket) => + socket.on("framereceived", ({ payload }) => collectRelease(released, payload)), + ); + return released; +} + +function collectRelease(released: Set, payload: string | Buffer) { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if (frame.message?.type === "subscription.release.response") + released.add(frame.message.payload.subscriptionId); +} + +async function expectReleased(released: Set, subscriptionId: string) { + await expect.poll(() => released.has(subscriptionId)).toBe(true); +} + +function readAlertsObservation(page: Page): Promise { + return page.evaluate( + () => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null, + ); +} + +/** The id of the observation the Bot alerts item opened after `previous`. */ +async function nextAlertsObservation(page: Page, previous: string | null): Promise { + await expect.poll(() => readAlertsObservation(page)).not.toBe(previous); + return (await readAlertsObservation(page))!; +} + +function sidebarFooter(page: Page): Locator { + return visibleTestId(page, "sidebar-add-project").locator("xpath=.."); +} + +test.describe("Plugin sidebar items", () => { + let workspaceId: string; + let cleanup: () => Promise; + + test.beforeEach(async () => { + const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" }); + const plugins = await installSidebarPlugins(); + workspaceId = workspace.workspaceId; + cleanup = async () => { + await plugins.cleanup(); + await workspace.cleanup(); + }; + }); + + test.afterEach(async () => { + await cleanup(); + }); + + test("a plugin's screen, header row, footer popover and command work together", async ({ + page, + }) => { + test.setTimeout(180_000); + // 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"); + const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync"); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(sync).toBeVisible(); + + await test.step("a throwing item renders nothing and leaves the sidebar working", async () => { + await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount( + 0, + ); + await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0); + await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0); + await expect(visibleTestId(page, "sidebar-settings")).toBeVisible(); + }); + + await test.step("the header row highlights while its screen is open", async () => { + await expectRowActive(row, false); + await row.click(); + await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`)); + await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible(); + await expect(screenTitle(page)).toHaveText("Deploys"); + await expectRowActive(row, true); + await qaScreenshot(page, "phase2-desktop-sidebar-screen"); + await closeScreen(page); + await expectRowActive(row, false); + }); + + await test.step("the trailing button presses on its own", async () => { + await page.getByRole("button", { name: "Refresh deploys", exact: true }).click(); + await expect(row).toHaveAccessibleName("Deploys (refreshed 1)"); + await expect(page).not.toHaveURL(/\/plugin\//); + await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0); + }); + + await test.step("the footer row renders as a row above the bottom line", async () => { + await expect(sync).toHaveAccessibleName("Sync"); + await expectFooterRow(page, sync); + await expect(visibleTestId(page, "sidebar-usage")).toBeVisible(); + await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page)); + }); + + await test.step("the footer row opens a popover anchored to it", async () => { + await sync.click(); + const popover = await popoverBox(page); + const button = (await sync.boundingBox())!; + await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible(); + expect(popover.y + popover.height).toBeLessThanOrEqual(button.y); + expect(button.y - popover.y).toBeLessThan(300); + expect(Math.abs(popover.x - button.x)).toBeLessThan(80); + await qaScreenshot(page, "phase2-desktop-popover"); + await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click(); + await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible(); + await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0); + await closeScreen(page); + }); + + await test.step("the command center item opens the screen", async () => { + await runCommand(page, "Open deploys"); + await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible(); + await expectRowActive(row, true); + await closeScreen(page); + }); + + await test.step("on a compact layout the popover is a bottom sheet", async () => { + await page.setViewportSize(COMPACT); + await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); + const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync"); + await expect(compactSync).toBeInViewport(); + await expectFooterRow(page, compactSync); + await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport(); + await qaScreenshot(page, "phase2-compact-sidebar"); + await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page)); + await compactSync.click(); + await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible(); + const sheet = await popoverBox(page); + expect(sheet.y).toBeGreaterThan(COMPACT.height / 2); + await qaScreenshot(page, "phase2-compact-sheet"); + await page.getByRole("button", { name: "Close sync details", exact: true }).click(); + await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0); + }); + }); + + test("a header row's shortcut hint presses the row", async ({ page }) => { + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace"); + await expect(newWorkspace).toBeVisible({ timeout: 30_000 }); + await newWorkspace.hover(); + const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true"); + await expect(hint).toBeVisible(); + await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../..")); + await hint.click(); + await expect(page).toHaveURL(/\/new(\?|$)/); + }); + + test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => { + test.setTimeout(180_000); + await page.addInitScript( + ({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })), + { + key: APP_SETTINGS_KEY, + sidebarNavItems: [ + { key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true }, + { key: "new-workspace", visible: true }, + { key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false }, + ], + }, + ); + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry"); + await expect(entry).toBeVisible({ timeout: 30_000 }); + let entryIcon = ""; + + await test.step("the saved order and visibility still apply", async () => { + await expect(entry).toHaveAccessibleName("Legacy entry"); + await expect(entry.locator("svg")).toHaveCount(1); + entryIcon = await entry.locator("svg").innerHTML(); + await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0); + const entryBox = (await entry.boundingBox())!; + const newWorkspaceBox = (await visibleTestId( + page, + "sidebar-global-new-workspace", + ).boundingBox())!; + expect(entryBox.y).toBeLessThan(newWorkspaceBox.y); + }); + + await test.step("the row opens its surface on its own sidebar route and highlights", async () => { + await entry.click(); + await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`)); + await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible(); + await expect(page.getByTestId("plugin-surface-close")).toBeVisible(); + await expect(screenTitle(page)).toHaveText("Legacy entry"); + await expectRowActive(entry, true); + await closeScreen(page); + }); + + await test.step("remembered sidebar routes keep resolving", async () => { + await page.goto( + `/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`, + ); + await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible(); + await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true); + }); + + await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => { + const host = encodeURIComponent(getServerId()); + await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`); + await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(screenTitle(page)).toHaveText("Bot 2"); + await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`); + await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`)); + await expect(screenTitle(page)).toHaveText("Bot"); + }); + + await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => { + await openSidebarNavSettings(page); + const legacyIcon = page + .getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`) + .locator("svg") + .first(); + const showcaseIcon = page + .getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`) + .locator("svg") + .first(); + const footerIcon = page + .getByTestId("sidebar-nav-section-footer") + .getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`) + .locator("svg") + .first(); + const markup = await showcaseIcon.innerHTML(); + expect(await footerIcon.innerHTML()).toBe(markup); + expect(await legacyIcon.innerHTML()).not.toBe(markup); + // The same registered icon the sidebar row draws. + expect(await legacyIcon.innerHTML()).toBe(entryIcon); + await qaScreenshot(page, "phase2-desktop-settings-sidebar"); + await qaScreenshot( + page, + "phase5-settings-sidebar-footer", + page.getByTestId("sidebar-nav-section-footer"), + ); + await page.setViewportSize(COMPACT); + await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached(); + await qaScreenshot(page, "phase2-compact-settings-sidebar"); + }); + }); + test("one item renders a group of rows, and each row opens its own screen params", async ({ + page, + }) => { + test.setTimeout(180_000); + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + const bot1 = botRow(page, "bot-1"); + const bot2 = botRow(page, "bot-2"); + const status = botRow(page, "status"); + await expect(bot1).toBeVisible({ timeout: 30_000 }); + + await test.step("the rows and the separator render in order in the header", async () => { + await expect(bot1).toHaveAccessibleName("Bot 1"); + await expect(bot2).toHaveAccessibleName("Bot 2"); + await expect(status).toHaveAccessibleName("Bot status"); + const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)]; + expect(second.y).toBeGreaterThan(first.y); + // The separator adds its line and margins between Bot 2 and the status row. + expect(third.y - (second.y + second.height)).toBeGreaterThan(8); + expect(second.y - (first.y + first.height)).toBeLessThan(4); + }); + + await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => { + await bot2.click(); + await expectBotScreen(page, "bot-2"); + await qaScreenshot(page, "phase6-desktop-sidebar-bots"); + }); + + await test.step("a reload keeps the screen and its params", async () => { + await page.reload(); + await expectBotScreen(page, "bot-2"); + await qaScreenshot(page, "phase6-bot-screen"); + }); + + await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => { + await bot1.click(); + await expectBotScreen(page, "bot-1"); + await page.goBack(); + await expectBotScreen(page, "bot-2"); + }); + + await test.step("a popover opened from a later row anchors to that row", async () => { + await status.click(); + await expectPopoverBeside(page, "Bot status details", status, bot1); + await page.getByRole("button", { name: "Close bot status", exact: true }).click(); + await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0); + }); + + await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => { + await page.getByRole("button", { name: "More for Bot 2", exact: true }).click(); + await expectPopoverBeside(page, "Bot 2 options", bot2, status); + await qaScreenshot(page, "phase6-desktop-popover-row"); + await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click(); + await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0); + }); + + await test.step("a param named like a route segment reaches the screen", async () => { + await runCommand(page, "Open bot 3 on server x"); + await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible(); + await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible(); + // No bot named bot-3, so the title function returns its own fallback. + await expect(screenTitle(page)).toHaveText("Bot"); + await page.reload(); + await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible(); + }); + + await test.step("Settings lists the group as one item", async () => { + await openSidebarNavSettings(page); + const header = page.getByTestId("sidebar-nav-section-header"); + await expect( + header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`), + ).toHaveCount(1); + await expect(header.getByText("Bots", { exact: true })).toHaveCount(1); + await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0); + await leaveSettings(page); + }); + + await test.step("the compact sidebar shows the same group", async () => { + await page.setViewportSize(COMPACT); + await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); + await expect(botRow(page, "bot-1")).toBeInViewport(); + await expect(botRow(page, "status")).toBeInViewport(); + await qaScreenshot(page, "phase6-compact-sidebar-bots"); + }); + }); + + test("a plugin adds and removes a footer item after setup", async ({ page }) => { + test.setTimeout(120_000); + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 }); + const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts"); + await expect(alerts).toHaveCount(0); + + await runCommand(page, "Add bot alerts"); + await expect(alerts).toBeVisible(); + await expect(alerts).toHaveAccessibleName("Bot alerts"); + await expectFooterRow(page, alerts); + + await runCommand(page, "Remove bot alerts"); + await expect(alerts).toHaveCount(0); + }); + + test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => { + test.setTimeout(120_000); + const released = trackReleasedSubscriptions(page); + const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts"); + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 }); + + await runCommand(page, "Add bot alerts"); + await expect(alerts).toBeVisible(); + const first = await nextAlertsObservation(page, null); + expect(released.has(first)).toBe(false); + + await test.step("hiding the item in Settings closes its observation", async () => { + await openSidebarNavSettings(page); + await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false); + await expectReleased(released, first); + await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true); + await leaveSettings(page); + }); + + await test.step("removing the item through its remover closes its observation", async () => { + await expect(alerts).toBeVisible(); + const second = await nextAlertsObservation(page, first); + expect(released.has(second)).toBe(false); + await runCommand(page, "Remove bot alerts"); + await expect(alerts).toHaveCount(0); + await expectReleased(released, second); + }); + }); +}); 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 e327bbd0ca..a17480b740 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -1,18 +1,83 @@ import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; +import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { + claudeAndCodexReports, + expectPinnedUsage, + openCompactSidebar, +} from "../support/helpers/usage-sidebar-item"; +import { gotoAppShell } from "../support/helpers/app"; +import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; +import { + expectFooterIconRow, + expectFooterSeparator, + hoverFooterAddProject, + footerScreenshot, + expectFooterItemHidden, + expectFooterOrder, + expectFooterSettingsKeys, expectSidebarItemHidden, expectSidebarNavSettingsOrder, expectSidebarNavSettingsRow, expectSidebarOrder, expectStoredSidebarNav, leaveSettings, + moveFooterItemUp, moveSidebarNavItemUp, openSidebarNavSettings, + seedSidebarFooterPreferences, seedSidebarNavPreferences, + setFooterItemVisible, setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; +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 }, + { 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"]); + await expectFooterIconRow(page); + await expectFooterSeparator(page, true); + await footerScreenshot(page, "footer-desktop-with-rows"); + await hoverFooterAddProject(page); + await footerScreenshot(page, "footer-desktop-add-project-tooltip"); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await openSidebarNavSettings(page); + await expectFooterSettingsKeys(page, ["usage"]); + await setFooterItemVisible(page, "usage", false); + await leaveSettings(page); + await expectFooterSeparator(page, false); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-desktop-without-rows"); + await page.setViewportSize({ width: 390, height: 844 }); + await openCompactSidebar(page); + await expectFooterSeparator(page, false); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-compact-without-rows"); + await page.locator('[data-testid="sidebar-add-project"]:visible').hover(); + await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0); + await footerScreenshot(page, "footer-compact-add-project-hover"); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await page.setViewportSize({ width: 1440, height: 900 }); + await openSidebarNavSettings(page); + await setFooterItemVisible(page, "usage", true); + await leaveSettings(page); + await page.setViewportSize({ width: 390, height: 844 }); + await openCompactSidebar(page); + await expectFooterSeparator(page, true); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-compact-with-rows"); +}); + test.describe("Sidebar items in Appearance settings", () => { test("owner reorders and hides top-level sidebar items", async ({ page }) => { await gotoAppShell(page); @@ -24,8 +89,8 @@ test.describe("Sidebar items in Appearance settings", () => { await test.step("the Sidebar section lists every item in the same order", async () => { await openSidebarNavSettings(page); // The section explains itself through the header's info tooltip, not a paragraph. - await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName( - "About Sidebar", + await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName( + "About Header", ); await expectSidebarNavSettingsOrder(page, [ "new-workspace", @@ -106,3 +171,69 @@ test.describe("Sidebar items in Appearance settings", () => { await expectSidebarItemHidden(page, "schedules"); }); }); + +test.describe("Sidebar footer rows in Appearance settings", () => { + const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`; + const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`; + let cleanup: () => Promise; + + test.beforeEach(async () => { + ({ cleanup } = await installSidebarPlugins()); + }); + + test.afterEach(async () => { + await cleanup(); + }); + + test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => { + test.setTimeout(120_000); + // The Usage item shows only with summary data. + 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 }, + ]); + await gotoAppShell(page); + + await test.step("the Footer card lists only the footer rows", async () => { + await openSidebarNavSettings(page); + await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName( + "About Footer", + ); + await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]); + await leaveSettings(page); + await expectFooterOrder(page, ["usage", syncKey]); + await expectFooterIconRow(page); + await expectFooterSeparator(page, true); + }); + + await test.step("moving Sync up and hiding the Usage item changes the rows", async () => { + await openSidebarNavSettings(page); + await moveFooterItemUp(page, syncKey); + await setFooterItemVisible(page, "usage", false); + await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]); + await leaveSettings(page); + await expectFooterItemHidden(page, "usage"); + await expect( + page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`), + ).toBeVisible(); + await expectFooterIconRow(page); + await expectFooterSeparator(page, true); + }); + + await test.step("the rows keep that shape across a reload", async () => { + await page.reload(); + await expect( + page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`), + ).toBeVisible({ timeout: 30_000 }); + await expectFooterItemHidden(page, "usage"); + await expectFooterIconRow(page); + await expectFooterSeparator(page, true); + await openSidebarNavSettings(page); + await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]); + }); + }); +}); 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-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts deleted file mode 100644 index 42df720db7..0000000000 --- a/packages/app/e2e/browser/usage-composer-pill.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { UsageReportEntry } from "@getpaseo/protocol/messages"; -import { expect, test, type Page } from "../support/fixtures"; -import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { installUsageReportsFixture } from "../support/helpers/usage-reports"; - -const REPORT_ID = "fixture:fixture-account"; - -// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. -function twoHoursAgo(): string { - return new Date(Date.now() - 2 * 60 * 60_000).toISOString(); -} - -function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry { - return { - id: REPORT_ID, - account: { label: "Fixture account" }, - fetchedAt, - sourceId: "fixture", - sourceLabel: "Fixture plan", - icon: '', - report: { - status: "available", - planLabel: "Test plan", - windows: [ - { id: "session", label: "Session", usedPct: 3 }, - { id: "weekly", label: "Weekly", usedPct, headline: true }, - ], - }, - }; -} - -async function openMockAgent(page: Page) { - const session = await seedMockAgentWorkspace({ - repoPrefix: "usage-composer-pill-", - title: "Usage composer pill e2e", - initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.", - }); - await openAgentRoute(page, session); - await expectComposerVisible(page); - return session; -} - -test.describe("usage composer pill", () => { - test("opens the cached report without a request and shows when it was fetched", async ({ - page, - }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { - agentReportIds: [REPORT_ID], - lists: [[agentEntry(42, twoHoursAgo())]], - }); - const session = await openMockAgent(page); - try { - const pill = page.getByTestId("usage-composer-pill"); - await expect(pill).toContainText("42%", { timeout: 30_000 }); - expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId }); - expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]); - - await pill.click(); - const popover = page.getByTestId("usage-composer-popover"); - await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible(); - await expect(popover.getByText("Test plan")).toBeVisible(); - await expect(popover.getByText("42%")).toBeVisible(); - - await popover.getByTestId("usage-refresh").hover(); - await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); - await expect(popover.getByTestId("usage-freshness")).toHaveCount(0); - expect(usage.listRequests()).toHaveLength(1); - } finally { - await session.cleanup(); - } - }); - - test("refreshes only its report from the popover", async ({ page }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { - agentReportIds: [REPORT_ID], - lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]], - }); - const session = await openMockAgent(page); - try { - const pill = page.getByTestId("usage-composer-pill"); - await expect(pill).toContainText("42%", { timeout: 30_000 }); - await pill.click(); - const popover = page.getByTestId("usage-composer-popover"); - await expect(popover.getByText("42%")).toBeVisible(); - - await popover.getByTestId("usage-refresh").click(); - await usage.waitForListRequests(2); - expect(usage.listRequests()).toEqual([ - { forceRefresh: false, reportIds: [REPORT_ID] }, - { forceRefresh: true, reportIds: [REPORT_ID] }, - ]); - await expect(popover.getByText("64%")).toBeVisible(); - await expect(pill).toContainText("64%"); - await expect(popover.getByTestId("usage-refresh")).toBeEnabled(); - - // Pressing closes the tooltip; it reopens on the next hover. - await popover.getByText("Fixture plan", { exact: true }).hover(); - await popover.getByTestId("usage-refresh").hover(); - await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now"); - expect(usage.listRequests()).toHaveLength(2); - } finally { - await session.cleanup(); - } - }); - - test("keeps the previous report and says so when a refresh fails", async ({ page }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { - agentReportIds: [REPORT_ID], - lists: [ - [agentEntry(42, twoHoursAgo())], - { error: "Usage source timed out" }, - () => [agentEntry(64, new Date().toISOString())], - ], - }); - const session = await openMockAgent(page); - try { - const pill = page.getByTestId("usage-composer-pill"); - await expect(pill).toContainText("42%", { timeout: 30_000 }); - await pill.click(); - const popover = page.getByTestId("usage-composer-popover"); - const refresh = popover.getByTestId("usage-refresh"); - - await refresh.click(); - await usage.waitForListRequests(2); - await expect(popover.getByTestId("usage-refresh-error")).toHaveText( - "Unable to refresh usage", - ); - await expect(popover.getByText("42%")).toBeVisible(); - await expect(pill).toContainText("42%"); - - await refresh.click(); - await usage.waitForListRequests(3); - await expect(popover.getByText("64%")).toBeVisible(); - await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0); - } finally { - await session.cleanup(); - } - }); - - test("prints the freshness on the card where there is no hover", async ({ page }) => { - test.setTimeout(180_000); - await page.setViewportSize({ width: 390, height: 844 }); - const usage = await installUsageReportsFixture(page, { - agentReportIds: [REPORT_ID], - lists: [[agentEntry(42, twoHoursAgo())]], - }); - const session = await openMockAgent(page); - try { - const pill = page.getByTestId("usage-composer-pill"); - await expect(pill).toContainText("42%", { timeout: 30_000 }); - await pill.click(); - const popover = page.getByTestId("usage-composer-popover"); - await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago"); - expect(usage.listRequests()).toHaveLength(1); - } finally { - await session.cleanup(); - } - }); - - test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { - agentReportIds: [REPORT_ID], - lists: [[agentEntry(42)]], - }); - const session = await openMockAgent(page); - try { - const pill = page.getByTestId("usage-composer-pill"); - await expect(pill).toContainText("42%", { timeout: 30_000 }); - const requestsBeforeTurn = usage.agentRequests().length; - const listRequestsBeforeTurn = usage.listRequests().length; - - await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill."); - - await usage.waitForAgentRequests(requestsBeforeTurn + 1); - expect(usage.agentRequests().at(-1)).toEqual({ - agentId: session.agentId, - }); - await expect(pill).toContainText("42%"); - expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn); - } finally { - await session.cleanup(); - } - }); - - test("is hidden when the agent has no usage report", async ({ page }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { agentReportIds: [null] }); - const session = await openMockAgent(page); - try { - await usage.waitForAgentRequests(1); - await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); - expect(usage.listRequests()).toHaveLength(0); - } finally { - await session.cleanup(); - } - }); - - test("is hidden on a host without usage sources", async ({ page }) => { - test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { - usageSources: false, - agentReportIds: [REPORT_ID], - lists: [[agentEntry(42)]], - }); - const session = await openMockAgent(page); - try { - await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); - expect(usage.agentRequests()).toHaveLength(0); - } finally { - await session.cleanup(); - } - }); -}); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 70d2ae10b8..8528f4a0ea 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -1,16 +1,105 @@ +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; 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 { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { + installUsageReportsFixture, + type UsageListRequest, + type UsageReportsFixture, +} from "../support/helpers/usage-reports"; +import { + installLoginUsage, + installCodexWindowUsage, + expectCodexReportedWindows, + openUsage, + refreshLoginUsage, + hoverUsageWindow, +} from "../support/helpers/usage-login"; +import { + claudeAndCodexReports, + expectPinnedUsage, + pinRow, + openUsageOptions, + showUsageAs, + refreshAllUsage, + togglePin, + usageItem, +} from "../support/helpers/usage-sidebar-item"; + +const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`); +test.use({ + e2eDaemonEnvironment: { + HOME: emptyHome, + USERPROFILE: emptyHome, + CODEX_HOME: emptyHome, + CLAUDE_CONFIG_DIR: emptyHome, + XDG_DATA_HOME: emptyHome, + PI_CODING_AGENT_DIR: emptyHome, + COPILOT_TOKEN: "", + GITHUB_TOKEN: "", + GITHUB_PAT: "", + CURSOR_ACCESS_TOKEN: "", + CURSOR_TOKEN: "", + GROK_API_KEY: "", + GROK_TOKEN: "", + KIMI_TOKEN: "", + KIMI_API_KEY: "", + KIMI_CODE_HOME: emptyHome, + MINIMAX_API_KEY: "", + ZAI_API_KEY: "", + GLM_API_KEY: "", + }, +}); + +// 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); +} // Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. function twoHoursAgo(): string { return new Date(Date.now() - 2 * 60 * 60_000).toISOString(); } +/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */ +async function qaScreenshot(page: Page, name: string) { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + await page.waitForTimeout(600); + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + await page.screenshot({ path: path.join(directory, `${name}.png`) }); +} + +function hostFilter(page: Page) { + return page.locator('[data-testid="usage-host-filter-trigger"]:visible'); +} + +function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { + return { + id: `${sourceId}:a`, + account: {}, + fetchedAt: twoHoursAgo(), + sourceId, + sourceLabel: `${sourceId} plan`, + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] }, + }; +} + test.describe("usage screen", () => { - test("opens from the sidebar and groups reports under their host", async ({ page }) => { + test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ + page, + }) => { test.setTimeout(120_000); const serverId = getServerId(); const usage = await installUsageReportsFixture(page, { @@ -24,7 +113,7 @@ test.describe("usage screen", () => { sourceLabel: "Alpha plan", report: { status: "available", - windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }], + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], }, }, { @@ -33,7 +122,10 @@ test.describe("usage screen", () => { fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "beta", sourceLabel: "Beta plan", - report: { status: "unavailable", windows: [] }, + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No active coding plan" }, + }, }, ], ], @@ -46,6 +138,8 @@ test.describe("usage screen", () => { const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(hostFilter(page)).toHaveCount(0); + await qaScreenshot(page, "phase7-usage-screen-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -66,7 +160,7 @@ test.describe("usage screen", () => { sourceLabel: "Beta plan", report: { status: "available", - windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }], + windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }], }, }; const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({ @@ -77,16 +171,16 @@ test.describe("usage screen", () => { sourceLabel: "Alpha plan", report: { status: "available", - windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }], + windows: [{ id: "weekly", label: "Weekly", usedPct }], }, }); + // The sidebar summary and the screen each load reports; only a card's Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ - [alpha(31, twoHoursAgo()), beta], - () => [ - alpha(58, new Date().toISOString()), - { ...beta, fetchedAt: new Date().toISOString() }, - ], + (request) => + request.forceRefresh + ? [alpha(58, new Date().toISOString())] + : [alpha(31, twoHoursAgo()), beta], ], }); @@ -97,11 +191,9 @@ test.describe("usage screen", () => { const alphaRefresh = group.getByTestId("usage-refresh").first(); await alphaRefresh.click(); - await usage.waitForListRequests(2); - expect(usage.listRequests()).toEqual([ - { forceRefresh: false, reportIds: undefined }, - { forceRefresh: true, reportIds: ["alpha:a"] }, - ]); + await expect + .poll(() => forcedRefreshes(usage)) + .toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]); await expect(group.getByText("58%")).toBeVisible(); await expect(group.getByText("12%")).toBeVisible(); @@ -138,19 +230,263 @@ test.describe("usage screen", () => { await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago"); }); - test("tells the user to update a host without usage sources", async ({ page }) => { + test("tells the user to update a host without usage support", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); - const usage = await installUsageReportsFixture(page, { usageSources: false }); + const usage = await installUsageReportsFixture(page, { usageSupported: false }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + // Without reports the footer has no Usage item; its Usage icon opens the screen. + await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click(); await expect( - page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", { - exact: true, - }), + page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), ).toBeVisible({ timeout: 10_000 }); + await qaScreenshot(page, "phase7-usage-update-host"); + await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0); + await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0); expect(usage.listRequests()).toHaveLength(0); }); + + test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({ + page, + }) => { + test.setTimeout(420_000); + const primaryServerId = getServerId(); + const secondary = await startIsolatedHostDaemon( + `srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`, + ); + try { + await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] }); + await installUsageReportsFixture(page, { + port: secondary.port, + lists: [[weeklyReport("beta", 12)]], + }); + await gotoAppShell(page); + await addConnectedHostAndReload(page, { + serverId: secondary.serverId, + label: "Secondary box", + port: secondary.port, + }); + + // Nothing picked and no workspace open: the first host. + await expectPinnedUsage(page, ["31% Weekly"]); + await usageItem(page).click(); + await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); + + await hostFilter(page).click(); + await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click(); + await expect( + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + ).toBeVisible({ timeout: 30_000 }); + await expect(hostFilter(page)).toContainText("Secondary box"); + await qaScreenshot(page, "usage-screen-picked-host"); + await expectPinnedUsage(page, ["12% Weekly"]); + + // The e2e seed resets the host list on every load, so reopening re-adds the second host. + await addConnectedHostAndReload(page, { + serverId: secondary.serverId, + label: "Secondary box", + port: secondary.port, + }); + await expectPinnedUsage(page, ["12% Weekly"]); + await expect( + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await secondary.close().catch(() => undefined); + } + }); }); + +const loginWindows: UsageReportEntry["report"] = { + status: "available", + windows: [ + { id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 }, + ], +}; + +test("expired login refreshes to windows with visible pin toggles", async ({ page }) => { + const fixture = await installLoginUsage({ + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }); + try { + await gotoAppShell(page); + await openUsage(page); + await expect( + page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); + await qaScreenshot(page, "usage-expired-login"); + await fixture.setReport(loginWindows); + await refreshLoginUsage(page); + await expect( + page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + ).toHaveCount(0); + await expectPinnedUsage(page, ["31% 5h", "54% wk"]); + const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); + await expect(row).toBeChecked(); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); + await hoverUsageWindow(page, "Weekly"); + await expect(page.getByText("Unpin", { exact: true })).toBeVisible(); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); + await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute( + "fill", + "none", + ); + await qaScreenshot(page, "usage-pin-hover"); + await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); + await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1"); + await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute( + "fill", + "none", + ); + await hoverUsageWindow(page, "Weekly"); + await expect(page.getByText("Pin", { exact: true })).toBeVisible(); + await expectPinnedUsage(page, ["31% 5h"]); + await qaScreenshot(page, "usage-pin-unselected"); + await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); + await expectPinnedUsage(page, ["31% 5h", "54% wk"]); + } finally { + await fixture.cleanup(); + } +}); + +test("compact usage rows always show the pin glyph", async ({ page }) => { + const fixture = await installLoginUsage(loginWindows); + try { + await page.setViewportSize({ width: 390, height: 844 }); + await gotoAppShell(page); + await openUsage(page); + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); + await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); + await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1"); + await qaScreenshot(page, "usage-pin-compact"); + } finally { + await fixture.cleanup(); + } +}); + +for (const shape of ["seven-day-only", "Spark"] as const) { + test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({ + page, + }) => { + const reset = Math.floor(Date.now() / 1000) + 5 * 86400; + const source = await installCodexWindowUsage({ + rate_limit: { + primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset }, + secondary_window: null, + }, + additional_rate_limits: + shape === "Spark" + ? [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { + used_percent: 0, + limit_window_seconds: 18000, + reset_at: Math.floor(Date.now() / 1000) + 3600, + }, + secondary_window: { + used_percent: 0, + limit_window_seconds: 604800, + reset_at: reset, + }, + }, + }, + ] + : [], + }); + try { + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoAppShell(page); + await expect(usageItem(page)).toBeVisible(); + await usageItem(page).click(); + await qaScreenshot(page, `codex-${shape}-card-and-summary`); + await expectCodexReportedWindows(page, shape); + await qaScreenshot(page, `codex-${shape}-pins`); + } finally { + await source.cleanup(); + } + }); +} + +for (const theme of ["light", "dark"] as const) { + for (const [size, viewport] of Object.entries({ + desktop: { width: 1440, height: 900 }, + compact: { width: 390, height: 844 }, + })) { + test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => { + test.setTimeout(120_000); + await page.addInitScript((value) => { + const key = "@paseo:app-settings"; + const current = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...current, theme: value })); + }, theme); + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize(viewport); + await page.goto("/usage"); + const screen = page.getByTestId(`usage-host-${getServerId()}`); + await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); + const settings = screen.getByRole("button", { name: "Settings", exact: true }); + const chevron = settings.locator("svg").locator("../.."); + await expect(settings).toHaveAccessibleName("Settings"); + await expect(settings).toHaveAttribute("aria-expanded", "false"); + await expect(chevron).toHaveCSS("transform", "none"); + await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); + await expect( + pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"), + ).toHaveCSS("opacity", "1"); + await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`); + + await togglePin(screen, "Claude", "Weekly"); + await page.mouse.move(0, viewport.height - 1); + await expect( + pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"), + ).toHaveCSS("opacity", "1"); + await openUsageOptions(page); + await expect(settings).toHaveAttribute("aria-expanded", "true"); + await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)"); + await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); + await expect(screen.getByTestId("usage-display-used")).toHaveAttribute( + "aria-selected", + "true", + ); + await settings.blur(); + await page.mouse.move(0, viewport.height - 1); + await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`); + await showUsageAs(page, "remaining"); + await expect(screen.getByText("69% left")).toBeVisible(); + await showUsageAs(page, "used"); + await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( + /^Pin Claude Session, 31% · resets /, + ); + await settings.click(); + await expect(settings).toHaveAttribute("aria-expanded", "false"); + await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); + await refreshAllUsage(page); + await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]); + }); + } +} + +async function captureSettingsState( + page: Page, + testInfo: import("@playwright/test").TestInfo, + name: string, +) { + await qaScreenshot(page, name); + await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 623bc51e67..04bfe649b1 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -3,14 +3,24 @@ import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; -import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + installUsageReportsFixture, + type UsageReportsFixture, +} from "../support/helpers/usage-reports"; +import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; const ICON = ''; +function forcedRefreshCount(usage: UsageReportsFixture): number { + return usage.listRequests().filter((request) => request.forceRefresh).length; +} + function report(input: { sourceId: string; sourceLabel: string; - report: Partial; + report: + | Partial> + | Exclude; }): UsageReportEntry { return { id: `${input.sourceId}:account`, @@ -19,11 +29,14 @@ function report(input: { sourceId: input.sourceId, sourceLabel: input.sourceLabel, icon: ICON, - report: { - status: "available", - windows: [], - ...input.report, - }, + report: + input.report.status === "error" || input.report.status === "unavailable" + ? input.report + : { + status: "available", + windows: [], + ...input.report, + }, }; } @@ -39,7 +52,7 @@ test.describe("usage settings", () => { sourceLabel: "Alpha plan", report: { planLabel: "Max", - windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }], + windows: [{ id: "session", label: "Session", usedPct: 7 }], }, }), report({ @@ -69,7 +82,6 @@ test.describe("usage settings", () => { await gotoAppShell(page); await openSettings(page); - expect(usage.listRequests()).toHaveLength(0); await openSettingsHostSection(page, serverId, "usage"); await usage.waitForListRequests(1); @@ -82,26 +94,29 @@ test.describe("usage settings", () => { await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); + + // Percentages now live on the Usage screen, and still apply to the host section. + await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0); + const hostUsageUrl = page.url(); + await page.goto("/usage"); + await showUsageAs(page, "remaining"); + await page.goto(hostUsageUrl); + await expect(card.getByText("30% left")).toBeVisible(); + await expect(card.getByText("93% left")).toBeVisible(); }); test("refresh forces a fresh report", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }]; + // The sidebar summary and the section each load reports; only Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ - [ + (request) => [ report({ sourceId: "alpha", sourceLabel: "Alpha plan", - report: { windows: windows(23) }, - }), - ], - [ - report({ - sourceId: "alpha", - sourceLabel: "Alpha plan", - report: { windows: windows(64) }, + report: { windows: windows(request.forceRefresh ? 64 : 23) }, }), ], ], @@ -110,26 +125,26 @@ test.describe("usage settings", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); - await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); + const card = page.getByTestId("usage-card"); + await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); - await usage.waitForListRequests(2); - - expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true }); - await expect(page.getByText("64%")).toBeVisible(); + await refreshAllUsage(page); + await expect.poll(() => forcedRefreshCount(usage)).toBe(1); + await expect(card.getByText("64%")).toBeVisible(); }); - test("asks to update a host without usage sources and never calls it", async ({ page }) => { + test("asks to update a host without usage support and never calls it", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); - const usage = await installUsageReportsFixture(page, { usageSources: false }); + const usage = await installUsageReportsFixture(page, { usageSupported: false }); await gotoAppShell(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); await expect( - page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }), + // Names the host: "Update Laptop to see usage". + page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/), ).toBeVisible({ timeout: 10_000 }); expect(usage.listRequests()).toHaveLength(0); }); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts new file mode 100644 index 0000000000..c2da27b401 --- /dev/null +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -0,0 +1,377 @@ +import path from "node:path"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHostSection } from "../support/helpers/settings"; +import { + expectFooterItemSetting, + expectFooterSeparator, + leaveSettings, + openSidebarNavSettings, + setFooterItemVisible, +} from "../support/helpers/sidebar-nav-settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + claudeAndCodexReports, + expectNoUsageItem, + expectOnUsageScreen, + expectPinnedUsage, + expectSummaryInSidebar, + expectUnpinnableRows, + leaveUsageScreen, + openCompactSidebar, + openUsageScreenFromIcon, + pinRow, + setSummaryInSidebar, + openUsageOptions, + showUsageAs, + togglePin, + usageItem, + usageSheet, + installTallUsageSource, +} from "../support/helpers/usage-sidebar-item"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { waitForSettledPosition } from "../support/helpers/sheet-layout"; + +const WIDE = { width: 1440, height: 900 }; +const COMPACT = { width: 390, height: 844 }; + +type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator }; + +/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */ +async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + // Let sheet and drawer animations settle so the image shows the final state. + await page.waitForTimeout(600); + // Expo's fast-refresh indicator sits over the footer's Hosts icon. + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + const file = path.join(directory, `${name}.png`); + if (area.kind === "element") { + await area.locator.screenshot({ path: file }); + return; + } + const clip = area.kind === "footer" ? await footerClip(page) : undefined; + await page.screenshot({ path: file, clip }); +} + +/** Includes summary rows above the fixed icon row. */ +async function footerClip(page: Page) { + return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!; +} + +test.describe("Usage item", () => { + test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + page, + }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(COMPACT); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await openCompactSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await usageItem(page).click(); + const content = page.getByTestId("sidebar-usage-sheet-content"); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + const bounds = (await content.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + } finally { + await source.cleanup(); + } + }); + test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ + page, + }) => { + test.setTimeout(180_000); + const serverId = getServerId(); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + const screen = page.getByTestId(`usage-host-${serverId}`); + + 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); + await expectFooterItemSetting(page, "usage", true); + await qaScreenshot(page, "compact-settings-sidebar-footer"); + await gotoAppShell(page); + await openCompactSidebar(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + 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"); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked(); + }); + + await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => { + await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); + // One host: no host filter, as on History. + await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount( + 0, + ); + await togglePin(screen, "Claude", "Weekly"); + await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked(); + await qaScreenshot(page, "default-pins-after-one-toggle"); + await togglePin(screen, "Codex", "Session"); + await expectPinnedUsage(page, ["31% 5h", "12% wk"]); + await expect(usageItem(page)).not.toHaveText("Usage"); + await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible(); + await qaScreenshot(page, "usage-screen-header", { + kind: "element", + locator: page.getByTestId("page-title").locator(".."), + }); + await qaScreenshot(page, "usage-card-refresh", { + kind: "element", + locator: screen.getByTestId("usage-report-claude:default"), + }); + }); + + await test.step("remaining flips the Usage item and the Usage screen", async () => { + await showUsageAs(page, "remaining"); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); + await expect( + screen.getByTestId("usage-report-claude:default").getByText("69% left"), + ).toBeVisible(); + await expect( + screen.getByTestId("usage-report-codex:default").getByText("88% left"), + ).toBeVisible(); + }); + + await test.step("on desktop the Usage item opens the Usage screen", async () => { + await gotoAppShell(page); + await expect(page).not.toHaveURL(/\/usage$/); + await usageItem(page).click(); + await expectOnUsageScreen(page); + await expect(page.getByTestId("usage-expanded")).toHaveCount(0); + }); + + await test.step("on a phone the Usage item opens a bottom sheet", async () => { + await page.setViewportSize(COMPACT); + await leaveUsageScreen(page); + await openCompactSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await qaScreenshot(page, "compact-footer"); + await usageItem(page).click(); + const sheet = usageSheet(page); + await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport(); + await expect(sheet.getByText("88% left")).toBeVisible(); + await expect(pinRow(sheet, "Claude", "Session")).toBeChecked(); + await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked(); + // The row reads its window, percent and reset; the checkbox state says it is pinned. + await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName( + /^Pin Claude Session, \d+% left( · .+)?$/, + ); + // The sheet carries the same inline Settings row and title-row Refresh all. + await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute( + "aria-expanded", + "false", + ); + await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); + await openUsageOptions(page); + await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-selected", + "true", + ); + await qaScreenshot(page, "compact-settings-expanded"); + await sheet.getByTestId("usage-options-toggle").click(); + await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0); + await expect(sheet).toBeVisible(); + await expect(page).not.toHaveURL(/\/usage$/); + await waitForSettledPosition(sheet); + const sheetBox = (await sheet.boundingBox())!; + // Settings adds a row; the sheet still reserves the top 20% as its backdrop. + expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); + expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); + await qaScreenshot(page, "compact-sheet"); + await qaScreenshot(page, "phase7-compact-sheet"); + // Tap the backdrop above the sheet. + await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2); + await expect(sheet).toHaveCount(0); + await page.setViewportSize(WIDE); + await usageItem(page).click(); + await expectOnUsageScreen(page); + }); + + await test.step("a reload keeps the pins and the toggle", async () => { + await page.reload(); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageOptions(page); + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-selected", + "true", + ); + await qaScreenshot(page, "usage-settings-expanded"); + await page.getByTestId("usage-options-toggle").click(); + }); + + await test.step("the Settings usage section shares the pins and the toggle", async () => { + const usageUrl = page.url(); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + const section = page.getByTestId("usage-card"); + await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 }); + await expect(pinRow(section, "Codex", "Weekly")).toBeChecked(); + await page.goto(usageUrl); + await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); + }); + + await test.step("unpinning everything stays empty after a reload", async () => { + await togglePin(screen, "Claude", "Session"); + await togglePin(screen, "Codex", "Weekly"); + await expectNoUsageItem(page); + await page.reload(); + await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); + await expectNoUsageItem(page); + await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); + await qaScreenshot(page, "explicit-empty-pins-after-reload"); + }); + + await test.step("Settings > Sidebar lists the Usage item", async () => { + await openSidebarNavSettings(page); + const footer = page.getByTestId("sidebar-nav-section-footer"); + await expect( + footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }), + ).toBeVisible(); + await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer }); + await leaveSettings(page); + }); + }); +}); + +test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({ + page, +}) => { + await installUsageReportsFixture(page, { + lists: [ + [ + { + id: "alpha:a", + account: {}, + fetchedAt: new Date().toISOString(), + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No active coding plan" }, + }, + }, + ], + ], + }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({ + timeout: 30_000, + }); + await expectNoUsageItem(page); + await expectFooterSeparator(page, false); + await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" }); + await page.locator('[data-testid="sidebar-usage-icon"]:visible').click(); + await expectOnUsageScreen(page); +}); + +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); + const screen = page.getByTestId(`usage-host-${serverId}`); + const claude = screen.getByTestId("usage-report-claude:default"); + + await test.step("off by default: no Usage item, and rows that do not pin", async () => { + await openUsageScreenFromIcon(page); + await 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 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 }) => { + await installUsageReportsFixture(page, { + 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"]); + // Source logos are decorative SVGs with no accessible role. Their path data distinguishes + // the source artwork from the fallback gauge. + const claudePath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!; + const codexPath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!; + const summary = usageItem(page).getByTestId("sidebar-usage-source"); + await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); + await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); + await qaScreenshot(page, "released-host-footer", { kind: "footer" }); + await usageItem(page).click(); + await expectOnUsageScreen(page); + const screen = page.getByTestId(`usage-host-${getServerId()}`); + await expect( + screen.getByTestId("usage-report-claude").locator("svg path").first(), + ).toHaveAttribute("d", claudePath); + await expect( + screen.getByTestId("usage-report-codex").locator("svg path").first(), + ).toHaveAttribute("d", codexPath); + await qaScreenshot(page, "released-host-usage-screen"); +}); 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/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 479a69ffb8..6158906f3f 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { restoreArchivedWorkspace, @@ -354,6 +355,88 @@ test.describe("Worktree restore", () => { .toBeNull(); }); + test("restores a branchless workspace so its archived agent can be used again", async ({ + page, + }) => { + const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); + createdProjectIds.add(project.projectKey); + const backingWorktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: `branchless-${randomUUID().slice(0, 8)}`, + }); + createdProjectIds.add(backingWorktree.projectKey); + createdWorktreeDirectories.add(backingWorktree.workspaceDirectory); + + execFileSync("git", ["switch", "--detach", "main"], { + cwd: backingWorktree.workspaceDirectory, + stdio: "pipe", + }); + const created = await worktreeClient.createWorkspace({ + source: { kind: "directory", path: backingWorktree.workspaceDirectory }, + title: "Sample workspace", + }); + if (!created.workspace) { + throw new Error(created.error ?? "Could not create the branchless workspace"); + } + const workspaceId = created.workspace.id; + const agent = await createIdleAgent(client, { + cwd: backingWorktree.workspaceDirectory, + workspaceId, + title: "Archived agent", + }); + + await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, { + scope: "worktree", + }); + await expect + .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull(); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + await expect( + page.getByText( + "Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.", + { exact: true }, + ), + ).toBeVisible(); + await captureBranchlessRestoreStep(page, "01-restore-workspace.png"); + + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect + .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({ + timeout: 30_000, + }); + await captureBranchlessRestoreStep(page, "02-unarchive-agent.png"); + + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable(); + await captureBranchlessRestoreStep(page, "03-agent-ready.png"); + + const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: backingWorktree.workspaceDirectory, + encoding: "utf8", + }).trim(); + const defaultHead = execFileSync("git", ["rev-parse", "main"], { + cwd: tempRepo.path, + encoding: "utf8", + }).trim(); + expect(restoredHead).toBe(defaultHead); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: backingWorktree.workspaceDirectory, + encoding: "utf8", + }).trim(), + ).toMatch(/^restored\//); + }); + test("restore failure stays visible and permits a successful retry", async ({ page }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; @@ -422,6 +505,12 @@ test.describe("Worktree restore", () => { }); }); +async function captureBranchlessRestoreStep(page: Page, filename: string): Promise { + const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence"); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true }); +} + function commitRestorableChange(cwd: string): void { writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n"); execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" }); diff --git a/packages/app/e2e/fixtures/catalog-codex.mjs b/packages/app/e2e/fixtures/catalog-codex.mjs new file mode 100644 index 0000000000..1b3826cb30 --- /dev/null +++ b/packages/app/e2e/fixtures/catalog-codex.mjs @@ -0,0 +1,88 @@ +import readline from "node:readline"; +import { randomUUID } from "node:crypto"; + +const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" }; +const ultra = { + id: "ultrafast", + name: "Ultrafast", + description: "Lowest latency, increased usage", +}; +const models = [ + { id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] }, + { id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] }, +].map((model) => + Object.assign(model, { + model: model.id, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + }), +); + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} +function respond(method, params) { + switch (method) { + case "initialize": + return {}; + case "model/list": + return { data: models }; + case "config/read": + return { config: { model: models[0].id, model_reasoning_effort: "medium" } }; + case "collaborationMode/list": + return { data: [] }; + case "skills/list": + return { data: [] }; + case "thread/list": + case "thread/loaded/list": + return { data: [] }; + case "thread/start": + return { thread: { id: randomUUID() }, model: params.model }; + case "thread/resume": + return { thread: { id: params.threadId } }; + case "thread/read": + return { thread: { id: params.threadId, turns: [] } }; + case "turn/start": { + const turnId = randomUUID(); + setTimeout(() => { + send({ + method: "turn/started", + params: { threadId: params.threadId, turn: { id: turnId } }, + }); + send({ + method: "item/completed", + params: { + threadId: params.threadId, + turnId, + item: { + id: randomUUID(), + type: "agentMessage", + text: "Ready to work on this project.", + }, + }, + }); + send({ + method: "turn/completed", + params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } }, + }); + }, 50); + return { turn: { id: turnId } }; + } + default: + throw new Error(`Unsupported Codex fixture request: ${method}`); + } +} + +if (process.argv.includes("--version")) { + console.log("codex-cli 0.159.0"); +} else { + readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + try { + send({ id: request.id, result: respond(request.method, request.params ?? {}) }); + } catch (error) { + send({ id: request.id, error: { code: -32601, message: error.message } }); + } + }); +} 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/mobile/agent-device/native-terminal-wide-keybar.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad new file mode 100644 index 0000000000..af7e25995e --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad @@ -0,0 +1,24 @@ +context platform=android timeout=90000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-new-tab-button\"" 45000 +press "id=\"workspace-new-tab-button\"" +wait "id=\"workspace-new-tab-menu-terminal\"" 10000 +press "id=\"workspace-new-tab-menu-terminal\"" +wait "id=\"terminal-key-esc\"" 10000 +is visible "id=\"terminal-virtual-keyboard-native-wide\"" +is visible "id=\"terminal-key-esc\"" +is visible "id=\"terminal-key-ctrl\"" +is visible "id=\"terminal-key-enter\"" +is visible "id=\"terminal-paste\"" +press "id=\"terminal-keyboard-toggle\"" +type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000 +press "id=\"terminal-key-ctrl\"" +type "c" --delay-ms 0 +type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_MODIFIER_RESET_OK__" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad new file mode 100644 index 0000000000..31c4bba393 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad @@ -0,0 +1,17 @@ +context platform=ios timeout=90000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-new-tab-button\"" 30000 +press "id=\"workspace-new-tab-button\"" +wait "id=\"workspace-new-tab-menu-terminal\"" 10000 +press "id=\"workspace-new-tab-menu-terminal\"" +wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000 +is visible "id=\"terminal-key-esc\"" +is visible "id=\"terminal-key-enter\"" +is visible "id=\"terminal-paste\"" +press "id=\"terminal-keyboard-toggle\"" +type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_IOS_KEY_OK__" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml b/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml new file mode 100644 index 0000000000..2cf92faf3c --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml @@ -0,0 +1,40 @@ +appId: sh.paseo.debug +--- +- launchApp +- runFlow: + when: + visible: ".*getRegistrationInfoAsync.*" + commands: + - tapOn: ".*getRegistrationInfoAsync.*" + - tapOn: Dismiss +- assertNotVisible: ".*getRegistrationInfoAsync.*" +- extendedWaitUntil: + visible: + id: workspace-new-tab-button + timeout: 30000 +- tapOn: + id: workspace-new-tab-button +- tapOn: + id: workspace-new-tab-menu-terminal +- extendedWaitUntil: + visible: + id: terminal-virtual-keyboard-native-wide + timeout: 10000 +- assertVisible: + id: terminal-key-esc +- assertVisible: + id: terminal-key-enter +- assertVisible: + id: terminal-paste +- tapOn: + id: terminal-paste +- runFlow: + when: + visible: Allow Paste + commands: + - tapOn: Allow Paste +- tapOn: + id: terminal-key-enter +- extendedWaitUntil: + visible: __AD_IOS_PASTE_OK__ + timeout: 10000 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md b/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md new file mode 100644 index 0000000000..c19cedaaf9 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md @@ -0,0 +1,18 @@ +# Native wide terminal device flows + +Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device. + +Android Pixel Tablet API 36.1: + +```sh +agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36 +``` + +iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`. + +```sh +printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A +agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' +``` + +The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states. diff --git a/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad b/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad new file mode 100644 index 0000000000..6a53573100 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad @@ -0,0 +1,12 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000 +longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900 +wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000 +is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" +press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" +wait "id=\"workspace-tabs-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad b/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad new file mode 100644 index 0000000000..fccfce679a --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad @@ -0,0 +1,14 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000 +longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900 +wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000 +is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" +press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" +wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000 +press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" +wait "id=\"agent-chat-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad b/packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad new file mode 100644 index 0000000000..1c5de2da8b --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad @@ -0,0 +1,17 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"context-window-meter\"" 20000 +press "id=\"context-window-meter\"" +wait "id=\"context-window-meter-tooltip\"" 10000 +press "id=\"tooltip-dismiss\"" +wait "id=\"context-window-meter\"" 10000 +press "id=\"context-window-meter\"" +wait "id=\"context-window-meter-tooltip\"" 10000 +press "id=\"tooltip-dismiss\"" +wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000 +press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" +wait "id=\"agent-chat-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad b/packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad new file mode 100644 index 0000000000..3561b8b150 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad @@ -0,0 +1,17 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"context-window-meter\"" 20000 +press "id=\"context-window-meter\"" +wait "id=\"context-window-meter-tooltip\"" 10000 +press "id=\"tooltip-dismiss\"" +wait "id=\"context-window-meter\"" 10000 +press "id=\"context-window-meter\"" +wait "id=\"context-window-meter-tooltip\"" 10000 +press "id=\"tooltip-dismiss\"" +wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000 +press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" +wait "id=\"agent-chat-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-menu-preconditions.md b/packages/app/e2e/mobile/agent-device/native-wide-menu-preconditions.md new file mode 100644 index 0000000000..cfb5c5f244 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-menu-preconditions.md @@ -0,0 +1,33 @@ +# Native wide menu flows + +Run these on a connected task-owned tablet with the current development app and Metro bundle. `workspace-tabs-scroll` asserts the wide tab row; also pass an explicit tablet `--device` so a landscape phone above the breakpoint cannot stand in for tablet coverage. The context meter flow requires an agent tab with reported token usage selected. Its tooltip assertion uses a stable ID emitted only by the open content. + +The agent tab menu flow requires an existing agent tab. Set `AGENT_ID` to the ID of that agent and `TAB_ID` to its workspace tab ID (shown in the `workspace-tab-context-*` test ID) before replay. The flow long-presses the tab and selects **Copy agent id**. It does not claim that an arrow tap works. + +The sidebar Rename flow requires a selected workspace and its `WORKSPACE_KEY` (shown in the `sidebar-workspace-kebab-*` test ID). It opens the row's actions, opens Rename, then cancels without changing the workspace name. This covers the currently working row reported in #5226. + +For the native-wide tooltip dismissal flows, select an agent with reported token usage so `context-window-meter` exists. A fresh load-test stream can supply usage. The Maestro flows assert that the tooltip is absent after dismissal, then reopen it to prove the control is usable. The overlay dismissal flows assert that the tab menu and Rename dialog are absent after selection/cancel. On iPad the focused Rename input can consume the first Cancel tap; the iOS Maestro flow conditionally repeats that tap only while the dialog remains visible. + +For tab scrolling, first create enough tabs for actual horizontal overflow. The Android replay was validated with nine tabs and the Explorer sidebar open; the iPad replay was validated with six tabs. Set `DRAFT_ID` to a visible draft tab, `AGENT_ID` to the first visible agent tab, `OTHER_AGENT_ID` to another agent tab, and `TAB_ID` to the first agent's workspace tab ID. The short pan starts on an agent chip while a draft is selected; the draft composer must remain active. The flow then checks a stationary long press and an ordinary tap. Keep the tab row scrolled to its beginning before running the iPad flow. + +Android Pixel Tablet API 36.1: + +```sh +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad --platform android --device paseo-tablet-p1-api36 --env OTHER_AGENT_ID= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad --platform android --device paseo-tablet-p1-api36 --env AGENT_ID= --env TAB_ID= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad --platform android --device paseo-tablet-p1-api36 --env WORKSPACE_KEY= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 --env AGENT_ID= --env TAB_ID= --env WORKSPACE_KEY= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad --platform android --device paseo-tablet-p1-api36 --env DRAFT_ID= --env AGENT_ID= --env OTHER_AGENT_ID= --env TAB_ID= +``` + +iPad Pro 11-inch (M5), iOS 26.5, from the Namespace Mac checkout: + +```sh +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env OTHER_AGENT_ID= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env AGENT_ID= --env OTHER_AGENT_ID= --env TAB_ID= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY= +agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env DRAFT_ID= --env AGENT_ID= --env OTHER_AGENT_ID= --env TAB_ID= +``` diff --git a/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml b/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml new file mode 100644 index 0000000000..39b11fd1af --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml @@ -0,0 +1,40 @@ +appId: sh.paseo.debug +--- +- launchApp +- extendedWaitUntil: + visible: + id: workspace-tabs-scroll + timeout: 30000 +- tapOn: + id: workspace-tab-agent_${AGENT_ID} +- longPressOn: + id: workspace-tab-agent_${AGENT_ID} +- assertVisible: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- tapOn: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- assertNotVisible: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- longPressOn: + id: workspace-tab-agent_${AGENT_ID} +- assertVisible: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- tapOn: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- assertNotVisible: + id: workspace-tab-context-${TAB_ID}-copy-agent-id +- tapOn: + id: sidebar-workspace-kebab-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-menu-rename-${WORKSPACE_KEY} +- assertVisible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel +- assertNotVisible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-kebab-${WORKSPACE_KEY} +- assertVisible: + id: sidebar-workspace-menu-rename-${WORKSPACE_KEY} +- stopApp diff --git a/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml b/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml new file mode 100644 index 0000000000..1504548142 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml @@ -0,0 +1,40 @@ +appId: sh.paseo.debug +--- +- launchApp +- runFlow: + when: + visible: ".*getRegistrationInfoAsync.*" + commands: + - tapOn: ".*getRegistrationInfoAsync.*" + - tapOn: Dismiss +- assertNotVisible: ".*getRegistrationInfoAsync.*" +- extendedWaitUntil: + visible: + id: workspace-tabs-scroll + timeout: 30000 +- tapOn: + id: sidebar-workspace-kebab-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-menu-rename-${WORKSPACE_KEY} +- assertVisible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel +- runFlow: + when: + visible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} + commands: + - tapOn: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel +- extendedWaitUntil: + notVisible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} + timeout: 10000 +- assertNotVisible: + id: sidebar-workspace-rename-modal-${WORKSPACE_KEY} +- tapOn: + id: sidebar-workspace-kebab-${WORKSPACE_KEY} +- assertVisible: + id: sidebar-workspace-menu-rename-${WORKSPACE_KEY} +- stopApp diff --git a/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad b/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad new file mode 100644 index 0000000000..49a070e02b --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad @@ -0,0 +1,13 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000 +press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" +wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000 +press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" +wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000 +press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\"" +wait "id=\"workspace-tabs-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad b/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad new file mode 100644 index 0000000000..ad637ca078 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad @@ -0,0 +1,16 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000 +press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" +wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000 +press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" +wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000 +press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\"" +press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\"" +wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 10000 +press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" +wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad b/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad new file mode 100644 index 0000000000..5016b8f576 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad @@ -0,0 +1,17 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +gesture pan 900 155 550 0 280 +press "id=\"workspace-tab-draft_${DRAFT_ID}\"" +wait "id=\"composer-import-agent-pill\"" 10000 +gesture pan 770 155 -310 0 280 +is visible "id=\"composer-import-agent-pill\"" +gesture pan 900 155 550 0 280 +longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900 +wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000 +press "label=\"Menu backdrop\"" +press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" +wait "id=\"agent-chat-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad new file mode 100644 index 0000000000..dcf10f72a1 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad @@ -0,0 +1,16 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 20000 +press "id=\"workspace-tab-draft_${DRAFT_ID}\"" +wait "id=\"composer-import-agent-pill\"" 10000 +gesture pan 450 86 -210 0 280 +is visible "id=\"composer-import-agent-pill\"" +gesture pan 450 86 240 0 280 +longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900 +wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000 +press "label=\"Menu backdrop\"" +press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" +wait "id=\"agent-chat-scroll\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml b/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml new file mode 100644 index 0000000000..17413bf207 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml @@ -0,0 +1,23 @@ +appId: sh.paseo.debug +--- +- launchApp +- extendedWaitUntil: + visible: + id: context-window-meter + timeout: 30000 +- tapOn: + id: context-window-meter +- assertVisible: + id: context-window-meter-tooltip +- tapOn: + id: tooltip-dismiss +- assertNotVisible: + id: context-window-meter-tooltip +- tapOn: + id: context-window-meter +- assertVisible: + id: context-window-meter-tooltip +- tapOn: + id: tooltip-dismiss +- assertNotVisible: + id: context-window-meter-tooltip diff --git a/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml b/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml new file mode 100644 index 0000000000..d048bf61d3 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml @@ -0,0 +1,30 @@ +appId: sh.paseo.debug +--- +- launchApp +- runFlow: + when: + visible: ".*getRegistrationInfoAsync.*" + commands: + - tapOn: ".*getRegistrationInfoAsync.*" + - tapOn: Dismiss +- assertNotVisible: ".*getRegistrationInfoAsync.*" +- extendedWaitUntil: + visible: + id: context-window-meter + timeout: 30000 +- tapOn: + id: context-window-meter +- assertVisible: + id: context-window-meter-tooltip +- tapOn: + id: tooltip-dismiss +- assertNotVisible: + id: context-window-meter-tooltip +- tapOn: + id: context-window-meter +- assertVisible: + id: context-window-meter-tooltip +- tapOn: + id: tooltip-dismiss +- assertNotVisible: + id: context-window-meter-tooltip diff --git a/packages/app/e2e/mobile/modal-sheet/README.md b/packages/app/e2e/mobile/modal-sheet/README.md index d95034dad4..8a59b16883 100644 --- a/packages/app/e2e/mobile/modal-sheet/README.md +++ b/packages/app/e2e/mobile/modal-sheet/README.md @@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run. journeys remain outside the default mobile suite, alongside the compact sheet journeys above. Keep generated screenshots, recordings, and run logs outside the repository. + +## Stacked adaptive sheets + +`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host +confirmation. It first types without tapping the input to verify autofocus, then cancels the +confirmation, closes and reopens the underlying sheet, confirms again, +and completes the password retry. `stacked-settings.ad` checks the same return and retry through +Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the +bottom sheet and centered dialog are exercised. + +These integration journeys require the host-confirmation flow in the test app. Include both the +sheet fix and that flow in the QA checkout when verifying them; the production sheet component +has no dependency on the host-confirmation feature. + +Use a fresh English app with notification permission granted and first-use keyboard prompts +already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected +daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream +setting otherwise forwards traffic to the hosted relay, which drops password rejection replies. +Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query +parameter, and `PASSWORD` to its correct password. This exercises password retry even while the +daemon accepts relay connections with no credential. + +```sh +agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \ + --state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \ + --udid "$SHEET_QA_IOS_UDID" \ + --env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \ + --env OUT="$SHEET_QA_OUTPUT" +``` + +For Settings, first open Settings with Add host visible. With a saved host, open Switch host +first. Use a second loopback spelling +(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation +of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments. +For an app with no saved hosts, use the original link; Add host is visible directly in Settings. + +Android uses the same journeys: copy each script outside the repository, change its context's +`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro +(including the port advertised by its manifest) and the local relay ports. Keep driver state, +artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the +default mobile suite, alongside the existing modal-sheet scripts. + +On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a +repeated-update error with both the parent and stacked-sheet implementations. Entering the complete +URI one character at a time passed with both implementations; faster typing remains unverified. +On iPad, replacing the field a second time appends text. For this Settings journey, use the +native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard +with Done to submit when the Connect button is below the keyboard. The welcome pairing regression +runs end to end without these adjustments. diff --git a/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad b/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad new file mode 100644 index 0000000000..d51d3ce060 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad @@ -0,0 +1,50 @@ +# Fresh English app at Welcome, local relay host with PASSWORD, see README.md. +# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet. +context platform=ios timeout=180000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"welcome-paste-pairing-link\"" 20000 +screenshot "${OUT}/01-welcome.png" +press "id=\"welcome-paste-pairing-link\"" +wait stable +# Type without tapping the input: opening the sheet must retain its autofocus. +wait "id=\"pair-link-input\"" 10000 +type "relay://" +wait "id=\"pair-link-input\" value=\"relay://\"" 5000 +screenshot "${OUT}/20-pairing-autofocus-without-tap.png" +fill "id=\"pair-link-input\"" "${PAIRING_LINK}" +wait stable +screenshot "${OUT}/02-pairing-input.png" +press "id=\"pair-link-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/03-confirmation-on-top.png" +press "id=\"host-confirmation-cancel\"" +wait stable +wait "id=\"pair-link-submit\" enabled=true" 10000 +screenshot "${OUT}/04-cancel-returns-to-pairing.png" +# Close and reopen the underlying sheet, then edit it and submit again. +press "id=\"pair-link-cancel\"" +wait stable +wait "id=\"welcome-paste-pairing-link\"" 10000 +screenshot "${OUT}/05-underlying-sheet-closes.png" +press "id=\"welcome-paste-pairing-link\"" +wait stable +wait "id=\"pair-link-input\"" 10000 +fill "id=\"pair-link-input\"" "${PAIRING_LINK}" +wait stable +press "id=\"pair-link-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/06-confirmation-reopens.png" +press "id=\"host-confirmation-connect\"" +wait stable +wait "id=\"pair-link-password-input\"" 20000 +screenshot "${OUT}/07-password-after-connect.png" +fill "id=\"pair-link-password-input\"" "${PASSWORD}" +wait stable +press "id=\"pair-link-submit\"" +wait stable +wait text "Open a folder on your machine" 30000 +screenshot "${OUT}/08-connected.png" diff --git a/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad b/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad new file mode 100644 index 0000000000..da687d7e02 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad @@ -0,0 +1,46 @@ +# Settings with Add host visible: no saved hosts, or the host picker already open. +context platform=ios timeout=180000 retries=0 +env APP_ID=sh.paseo.debug +open "${APP_ID}" +wait "id=\"settings-add-host\"" 20000 +screenshot "${OUT}/09-settings.png" +press "id=\"settings-add-host\"" +wait stable +wait "id=\"add-host-method-direct\"" 10000 +screenshot "${OUT}/10-add-host-methods.png" +press "id=\"add-host-method-direct\"" +wait stable +press "id=\"direct-host-advanced-toggle\"" +wait stable +fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}" +wait stable +screenshot "${OUT}/11-add-host-relay-input.png" +press "id=\"direct-host-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/12-add-host-confirmation-on-top.png" +press "id=\"host-confirmation-cancel\"" +wait stable +wait "id=\"direct-host-submit\" enabled=true" 10000 +screenshot "${OUT}/13-cancel-returns-to-add-host.png" +# Editing the underlying sheet proves it still accepts input after closing the top sheet. +fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}" +wait stable +press "id=\"direct-host-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/14-add-host-confirmation-reopens.png" +press "id=\"host-confirmation-connect\"" +wait stable +wait "id=\"direct-password-input\"" 20000 +screenshot "${OUT}/15-add-host-password.png" +fill "id=\"direct-password-input\"" "${PASSWORD}" +wait stable +press "id=\"direct-host-submit\"" +wait stable +open "${APP_ID}" "paseo://settings" +wait "label=\"Connections\"" 10000 +press "label=\"Connections\"" +wait stable +wait "id=\"host-page-connections-card\"" 30000 +screenshot "${OUT}/16-settings-connected.png" diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md new file mode 100644 index 0000000000..b92666cc58 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/README.md @@ -0,0 +1,26 @@ +# Native iOS text geometry regression + +Run on the Namespace Mac with a fresh simulator development build. Use the PID +returned by `xcrun simctl launch sh.paseo.debug`. +Attach only to your task's app, while no device replay is running: + +```sh +PASEO_NATIVE_TEXT_REFLOW_PID=12345 \ +PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \ +bash packages/app/e2e/mobile/native-text-reflow/ios.sh +``` + +The probe constructs the installed `RNUITextView`, fills its real UIKit text view, +and resizes its content container from 500 to 240 and back to 500 points. It +asserts the inner frame follows both widths and the same selection survives. +It uses public UIView/UITextView operations, without mocking React Native. +LLDB detaches after the probe. An assertion failure or evaluation error fails the +shell command; the log distinguishes them. + +This covers native container resizing and selection retention. It does not cover +Fabric event delivery, chat row height, reader anchoring, or the on-screen copy +menu. Those require the real chat journey: read an older multiline response, +open/close Explorer, resize/rotate, inspect complete paragraphs and bullets, +retain expanded/collapsed details, and check a stationary reader's position. +Accessibility exposes full text even when glyphs are clipped, so a text wait +alone does not verify that journey. diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py new file mode 100644 index 0000000000..7a4cbd5125 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/ios.py @@ -0,0 +1,55 @@ +import lldb + + +def __lldb_init_module(debugger, _internal_dict): + debugger.HandleCommand("expression -l objc++ -- @import UIKit") + frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame() + options = lldb.SBExpressionOptions() + options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus) + result = frame.EvaluateExpression( + r'''({ + UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init]; + auto hostFrame = host.frame; + hostFrame.size.width = 500; + hostFrame.size.height = 200; + host.frame = hostFrame; + UIView *container = (UIView *)[host performSelector:@selector(contentView)]; + container.frame = host.bounds; + UITextView *text = nil; + for (UIView *child in host.subviews) { + if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child; + for (UIView *nested in child.subviews) { + if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested; + } + } + text.frame = container.bounds; + text.font = [UIFont systemFontOfSize:16]; + text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection."; + auto range = text.selectedRange; + range.location = 12; + range.length = 9; + text.selectedRange = range; + auto frame = container.frame; + frame.size.width = 240; + container.frame = frame; + [container layoutIfNeeded]; + CGFloat narrow = text.frame.size.width; + auto selection = text.selectedRange; + frame.size.width = 500; + container.frame = frame; + [container layoutIfNeeded]; + CGFloat wide = text.frame.size.width; + BOOL ok = text != nil && narrow == 240 && wide == 500 && + selection.location == text.selectedRange.location && + selection.length == text.selectedRange.length && + selection.location == 12 && selection.length == 9; + [NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu", + ok ? @"PASS" : @"FAIL", narrow, wide, + (unsigned long)text.selectedRange.location, + (unsigned long)text.selectedRange.length]; + })''', + options, + ) + if result.GetError().Fail(): + raise RuntimeError(result.GetError().GetCString()) + print(result.GetObjectDescription()) diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh new file mode 100644 index 0000000000..b4e9a07a22 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/ios.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}" +: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}" + +if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then + echo "Expected a running PaseoDebug simulator app" >&2 + exit 1 +fi + +probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \ + -o "command script import $probe_dir/ios.py" \ + -o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1 + +grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG" +grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG" diff --git a/packages/app/e2e/support/helpers/add-project-flow.ts b/packages/app/e2e/support/helpers/add-project-flow.ts index c7b15df456..bb88264d37 100644 --- a/packages/app/e2e/support/helpers/add-project-flow.ts +++ b/packages/app/e2e/support/helpers/add-project-flow.ts @@ -47,7 +47,7 @@ async function openAddProjectFlowSurface( page: Page, expectedPage: "host" | "method", ): Promise { - await page.getByTestId("sidebar-add-project").click(); + await page.locator('[data-testid="sidebar-add-project"]:visible').click(); await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 }); await expectAddProjectPage(page, expectedPage); } diff --git a/packages/app/e2e/support/helpers/agent-skills.ts b/packages/app/e2e/support/helpers/agent-skills.ts index 76c7a56d4a..5d8c15633c 100644 --- a/packages/app/e2e/support/helpers/agent-skills.ts +++ b/packages/app/e2e/support/helpers/agent-skills.ts @@ -22,7 +22,7 @@ type AgentSkillsClient = Pick< "close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection" >; -export type SkillTargetName = "agents" | "claude" | "codex"; +export type SkillTargetName = "agents" | "claude"; export interface AgentSkillsSandbox { available: string[]; @@ -30,6 +30,7 @@ export interface AgentSkillsSandbox { daemon: IsolatedHostDaemon; home: string; targets: Record; + codexSkillsDir: string; blockAgentsDirectory(): Promise; unblockAgentsDirectory(): Promise; close(): Promise; @@ -63,8 +64,8 @@ export async function startAgentSkillsSandbox(): Promise { const targets = { agents: path.join(home, ".agents", "skills"), claude: path.join(home, ".claude", "skills"), - codex: path.join(home, ".codex", "skills"), }; + const codexSkillsDir = path.join(home, ".codex", "skills"); return { available, @@ -72,6 +73,7 @@ export async function startAgentSkillsSandbox(): Promise { daemon, home, targets, + codexSkillsDir, blockAgentsDirectory: async () => { await mkdir(path.dirname(targets.agents), { recursive: true }); await writeFile(targets.agents, "not a directory", "utf8"); @@ -196,6 +198,7 @@ export async function expectInstalledSkills( for (const directory of Object.values(sandbox.targets)) { await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort()); } + await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]); } export async function expectSavedSelection( diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts new file mode 100644 index 0000000000..2b76a78e85 --- /dev/null +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -0,0 +1,166 @@ +import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expectComposerVisible } from "./composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent"; +import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports"; +import { claudeAndCodexReports } from "./usage-sidebar-item"; + +// 32,000 of the mock's 128,000-token window. +const METER_NAME = "Context window 25% used"; + +export type MockAgentSession = Awaited>; + +/** An agent a quarter into its context window. */ +export function seedAgentWithContextWindow(): Promise { + return seedMockAgentWorkspace({ + repoPrefix: "context-window-usage-", + title: "Context window usage e2e", + initialPrompt: "emit 32000 byte file agent stream payload", + }); +} + +export async function openAgent(page: Page, session: MockAgentSession): Promise { + await openAgentRoute(page, session); + await expectComposerVisible(page); +} + +export async function reloadAgent(page: Page): Promise { + await page.reload({ waitUntil: "commit" }); + await expectComposerVisible(page); +} + +/** Wide screens show the meter's details in a tooltip while the pointer is on it. */ +export async function hoverContextWindowMeter(page: Page): Promise { + await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 }); + const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" }); + await expect(tooltip).toBeVisible(); + return tooltip; +} + +export async function leaveContextWindowMeter(page: Page): Promise { + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0); +} + +/** Compact screens open the meter's details in a sheet. */ +export async function pressContextWindowMeter(page: Page): Promise { + await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 }); + const sheet = contextWindowSheet(page); + await expect(sheet).toBeVisible(); + return sheet; +} + +export async function closeContextWindowSheet(page: Page): Promise { + const sheet = contextWindowSheet(page); + await sheet.getByRole("button", { name: "Close", exact: true }).click(); + await expect(sheet).toHaveCount(0); +} + +export function contextWindowSheet(page: Page): Locator { + return page.getByRole("dialog", { name: "Context window" }); +} + +/** One report card; cards have no role of their own. */ +export function usageCard(surface: Locator, reportId: string): Locator { + return surface.getByTestId(`usage-report-${reportId}`); +} + +export async function refreshUsageCard(surface: Locator, source: string): Promise { + await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click(); +} + +export async function expectRefreshButtons(surface: Locator, count: number): Promise { + await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count); +} + +/** A report on the agent's own login, which is not the host's default one. */ +export function onWorkLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "work"); +} + +/** A report on the login an agent resumed under. */ +export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "personal"); +} + +function onLogin(report: UsageReportEntry, login: string): UsageReportEntry { + return { + ...report, + id: `${report.sourceId}:${login}`, + account: { label: `${login}@example.com` }, + }; +} + +export function expiredLogin(report: UsageReportEntry): UsageReportEntry { + return { + ...report, + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + refreshedBy: "claude", + }, + }, + }; +} + +/** Holds a stream at a point until the test opens it, like a slow source. */ +export function gate(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; +} + +export interface AgentUsageScript { + /** The answer to the next agent usage request or report Refresh. */ + answerNext(response: UsageListResponse): void; + /** From the next reload the host no longer reports usage. */ + stopReportingUsage(): void; + /** The agent of each agent usage request, in order. */ + agentRequests(): string[]; + /** Each report Refresh, by report ID. */ + refreshedReports(): string[][]; +} + +/** + * Scripts the host's usage: the host's own list is Claude and Codex on its default login; each + * agent request and each Refresh takes the next scripted answer. + */ +export async function scriptAgentUsage(page: Page): Promise { + let supported = true; + const answers: UsageListResponse[] = []; + const usage = await installUsageReportsFixture(page, { + usageSupported: () => supported, + lists: [ + (request) => { + if (!request.agentId && !request.reportIds) return claudeAndCodexReports(); + const next = answers.shift(); + if (!next) throw new Error("The test scripts every agent usage request."); + return next; + }, + ], + }); + return { + answerNext: (response) => answers.push(response), + stopReportingUsage: () => { + supported = false; + }, + agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), + refreshedReports: () => + usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + }; +} + +/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */ +export async function qaScreenshot(page: Page, name: string): Promise { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + // Let the sheet and tooltip animations settle. + await page.waitForTimeout(600); + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + await page.screenshot({ path: path.join(directory, `${name}.png`) }); +} diff --git a/packages/app/e2e/support/helpers/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/host-confirmation.ts b/packages/app/e2e/support/helpers/host-confirmation.ts new file mode 100644 index 0000000000..f6de576fc0 --- /dev/null +++ b/packages/app/e2e/support/helpers/host-confirmation.ts @@ -0,0 +1,95 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; + +export interface LinkedHost { + serverId: string; + relayEndpoint: string; + daemonPublicKeyB64: string; +} + +/** + * A pairing link that opens on the settings page. `/` redirects to + * `/open-project` on its own, so starting in settings leaves the pairing link + * as the only thing that can move the app to `/open-project`. + */ +export function buildPairingLink(host: LinkedHost): string { + const offer = { + v: 2, + serverId: host.serverId, + daemonPublicKeyB64: host.daemonPublicKeyB64, + relay: { endpoint: host.relayEndpoint, useTls: false }, + }; + const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url"); + return `/settings/general#offer=${encoded}`; +} + +/** Loads the app from a pairing link, the way opening the link in a browser does. */ +export async function openPairingLink(page: Page, link: string): Promise { + // Leave the link's page first so the link always loads the app from scratch. + await page.goto("/"); + await page.goto(link); +} + +/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */ +export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise { + await page.goto("/"); + const nowIso = new Date().toISOString(); + const connectionId = `relay:${host.relayEndpoint}`; + const savedHost = { + serverId: host.serverId, + label: host.serverId, + connections: [ + { + id: connectionId, + type: "relay", + relayEndpoint: host.relayEndpoint, + useTls: false, + daemonPublicKeyB64: host.daemonPublicKeyB64, + }, + ], + preferredConnectionId: connectionId, + createdAt: nowIso, + updatedAt: nowIso, + }; + await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), { + key: EXTRA_HOSTS_KEY, + hosts: [savedHost], + }); +} + +export function hostConfirmation(page: Page): Locator { + return page.getByTestId("host-confirmation"); +} + +export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise { + const sheet = hostConfirmation(page); + await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId); + await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint); +} + +export async function cancelHostConfirmation(page: Page): Promise { + await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(hostConfirmation(page)).toHaveCount(0); +} + +export async function connectToConfirmedHost(page: Page): Promise { + await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click(); + await expect(hostConfirmation(page)).toHaveCount(0); +} + +/** Checks the host picker on the settings page the app is already showing. */ +export async function expectHostInHostPicker( + page: Page, + serverId: string, + expected: "listed" | "not listed", +): Promise { + await page.getByTestId("settings-host-picker").click(); + await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount( + expected === "listed" ? 1 : 0, + ); + await page.keyboard.press("Escape"); +} diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 0bf2fc16e9..718b0d5965 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; +import { openGlobalNewWorkspaceComposer } from "./new-workspace"; import { getServerId } from "./server-id"; import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar"; @@ -18,16 +19,24 @@ export class ImportSessionFlow { timeout: 30_000, }); } - async revealMobileEntryPoint() { - await openMobileAgentSidebar(this.page); - await expectMobileAgentSidebarVisible(this.page); - const button = this.page.getByTestId("sidebar-import-session"); + /** The new workspace screen's Import session button, reached from the sidebar. */ + async revealNewWorkspaceEntryPoint() { + const compact = (this.page.viewportSize()?.width ?? 0) < 768; + if (compact) { + await openMobileAgentSidebar(this.page); + await expectMobileAgentSidebarVisible(this.page); + } + await openGlobalNewWorkspaceComposer(this.page); + const button = this.page.getByTestId("new-workspace-import-session"); await expect(button).toHaveAccessibleName("Import session"); await expect(button).toBeInViewport(); } async openGlobally() { - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); - await this.page.getByTestId("sidebar-import-session").click(); + await this.revealNewWorkspaceEntryPoint(); + await this.openFromNewWorkspace(); + } + async openFromNewWorkspace() { + await this.page.getByTestId("new-workspace-import-session").click(); await this.expectSheetReady(); } async openFromWorkspaceHeader() { @@ -108,8 +117,12 @@ export class ImportSessionFlow { }); } async expectTranscript(userText: string, assistantText: string) { - await expect(this.page.getByTestId("user-message")).toContainText(userText); - await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText); + await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); + await expect( + this.page.getByTestId("assistant-message").filter({ visible: true }), + ).toContainText(assistantText); } async showAll() { await this.page.getByTestId("import-session-show-all").click(); @@ -120,7 +133,9 @@ export class ImportSessionFlow { timeout: 30_000, }); const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`); - await expect(workspace.getByTestId("user-message")).toContainText(userText); + await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); } async close() { await this.page.keyboard.press("Escape"); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index dace580491..63034d52a3 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer( } export async function openGlobalNewWorkspaceComposer(page: Page): Promise { - await page.getByTestId("sidebar-global-new-workspace").click(); + // The shell keeps a hidden copy of the other layout's sidebar mounted. + await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click(); await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000, diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 15e5c8bc37..7404173fc8 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) { React.useEffect(() => { const owner = paseo.observeEvents(["project.update"]); owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } }); - // Deliberately leave this observation to the mounted host scope. + return () => { void owner.release(); }; }, [paseo]); const rpc = useRpc(summary); const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) }); diff --git a/packages/app/e2e/support/helpers/plugin-sidebar-items.ts b/packages/app/e2e/support/helpers/plugin-sidebar-items.ts new file mode 100644 index 0000000000..0722bd8317 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-sidebar-items.ts @@ -0,0 +1,268 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { openCommandCenter } from "./command-center"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +export const SHOWCASE_PLUGIN_ID = "sidebar-showcase"; +export const LEGACY_PLUGIN_ID = "legacy-sidebar"; +export const BOTS_PLUGIN_ID = "sidebar-bots"; +export const WIDE = { width: 1440, height: 900 }; +export const COMPACT = { width: 390, height: 844 }; + +/** A plugin written against the current API: a screen, header and footer items, a command. */ +const SHOWCASE_SOURCE = `import React from "react"; +import { Pressable, Text, View } from "react-native"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; + +function DeploysScreen({ theme }) { + return Deploys screen body; +} + +function RefreshButton({ theme, onPress }) { + return ( + + Refresh + + ); +} + +function DeploysItem({ theme, currentScreen, openScreen }) { + const [refreshes, setRefreshes] = React.useState(0); + return ( + openScreen({ screenId: "deploys" })} + trailing={ setRefreshes((count) => count + 1)} />} + /> + ); +} + +function SyncDetails({ theme, layout, close, openScreen }) { + return ( + + Sync details + Presentation: {layout.compact ? "compact" : "wide"} + openScreen({ screenId: "deploys" })}>Open deploys + Done + + ); +} + +function SyncItem({ openPopover }) { + return openPopover(SyncDetails)} />; +} + +function Broken() { + throw new Error("Sidebar item exploded"); +} + +export default function contribute(client) { + client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen }); + client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem }); + client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken }); + client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem }); + client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken }); + client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) }); + return () => {}; +} +`; + +/** + * One header item that renders a group: a row per bot, a separator and a status row. Each bot + * row opens the bot screen with the bot's id as a param, and its trailing More button opens that + * bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item + * after setup; that item opens an event observation, releases it in its cleanup, and publishes its + * id on `globalThis.__botAlertsObservation`. + */ +const BOTS_SOURCE = `import React from "react"; +import { Pressable, Text, View } from "react-native"; +import { usePaseo } from "@getpaseo/plugin/client"; +import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui"; + +const BOTS = [ + { id: "bot-1", name: "Bot 1" }, + { id: "bot-2", name: "Bot 2" }, +]; + +function BotScreen({ theme, params }) { + return ( + + {"Bot screen: " + params.botId} + {"serverId param: " + (params.serverId ?? "none")} + + ); +} + +function botTitle(params) { + return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot"; +} + +const botMenus = {}; + +function botMenu(bot) { + botMenus[bot.id] ??= function BotMenu({ theme, close }) { + return ( + + {bot.name + " options"} + Done + + ); + }; + return botMenus[bot.id]; +} + +function MoreButton({ theme, bot, onPress }) { + return ( + + More + + ); +} + +function BotStatus({ theme, close }) { + return ( + + Bot status details + Done + + ); +} + +function BotsItem({ theme, currentScreen, openScreen, openPopover }) { + return ( + <> + {BOTS.map((bot) => ( + openScreen({ screenId: "bot", params: { botId: bot.id } })} + trailing={ openPopover(botMenu(bot))} />} + /> + ))} + + openPopover(BotStatus)} /> + + ); +} + +function AlertsItem() { + const paseo = usePaseo(); + React.useEffect(() => { + const observation = paseo.observeEvents(["project.update"]); + observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} }); + return () => { void observation.release(); }; + }, [paseo]); + return {}} />; +} + +export default function contribute(client) { + let removeAlerts = null; + client.addScreen({ id: "bot", title: botTitle, Component: BotScreen }); + client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem }); + client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) }); + client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => { + if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem }); + } }); + client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => { + removeAlerts?.(); + removeAlerts = null; + } }); + return () => {}; +} +`; + +/** A plugin that still uses only the old descriptor calls. */ +const LEGACY_SOURCE = `import React from "react"; +import { Text, View } from "react-native"; + +function Main({ theme }) { + return Legacy screen body; +} + +export default function contribute(plugin) { + plugin.addSurface("main", Main); + plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" }); + plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" }); + return () => {}; +} +`; + +export async function installSidebarPlugins() { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const config = await client.getDaemonConfig(); + const directories: string[] = []; + for (const [id, source] of [ + [SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE], + [LEGACY_PLUGIN_ID, LEGACY_SOURCE], + [BOTS_PLUGIN_ID, BOTS_SOURCE], + ] as const) { + const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`)); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), source); + } + await client.patchDaemonConfig({ pluginsEnabled: true }); + for (const directory of directories) await client.installDirectoryPlugin(directory); + return { + async cleanup() { + await client.removePlugin(SHOWCASE_PLUGIN_ID); + await client.removePlugin(LEGACY_PLUGIN_ID); + await client.removePlugin(BOTS_PLUGIN_ID); + await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled }); + await client.close(); + await Promise.all( + directories.map((directory) => rm(directory, { recursive: true, force: true })), + ); + }, + }; +} + +/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */ +export function visibleTestId(page: Page, testID: string): Locator { + return page.locator(`[data-testid="${testID}"]:visible`).first(); +} + +export function headerRow(page: Page, pluginId: string, itemId: string): Locator { + return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`); +} + +export function footerItem(page: Page, pluginId: string, itemId: string): Locator { + return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`); +} + +export async function expectRowActive(row: Locator, active: boolean): Promise { + if (active) await expect(row).toHaveAttribute("aria-selected", "true"); + else await expect(row).not.toHaveAttribute("aria-selected", "true"); +} + +export async function runCommand(page: Page, title: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await panel.getByRole("button", { name: title, exact: true }).click(); + await expect(panel).not.toBeVisible(); +} + +export async function closeScreen(page: Page): Promise { + await page.getByTestId("plugin-surface-close").click(); + await expect(page).not.toHaveURL(/\/plugin\//); +} + +/** Box of the popover body, located from the text it renders. */ +export async function popoverBox(page: Page, text = "Sync details") { + const details = page.getByText(text, { exact: true }); + await expect(details).toBeInViewport(); + const box = await details.boundingBox(); + expect(box).not.toBeNull(); + return box!; +} diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b5f1a77683..b707abdae8 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { expect, type Locator, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings"; @@ -55,10 +56,25 @@ export async function seedSidebarNavPreferences( ); } +/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */ +export async function seedSidebarFooterPreferences( + page: Page, + preferences: SidebarNavPreference[], +): Promise { + await page.addInitScript( + ({ key, sidebarFooterItems }) => { + if (localStorage.getItem(key)) return; + localStorage.setItem(key, JSON.stringify({ sidebarFooterItems })); + }, + { key: APP_SETTINGS_KEY, sidebarFooterItems: preferences }, + ); +} + export async function openSidebarNavSettings(page: Page): Promise { await openSettings(page); await openSettingsSection(page, "sidebar"); - await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible(); } export async function leaveSettings(page: Page): Promise { @@ -125,9 +141,9 @@ export async function expectStoredSidebarNav( .toEqual(expected); } -async function expectVerticalOrder( - keys: SidebarNavKey[], - locate: (key: SidebarNavKey) => Locator, +async function expectVerticalOrder( + keys: Key[], + locate: (key: Key) => Locator, subject: string, ): Promise { await expect @@ -136,11 +152,7 @@ async function expectVerticalOrder( const measured = await Promise.all( keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })), ); - if ( - !measured.every( - (entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null, - ) - ) + if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null)) return null; return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key); }, @@ -148,3 +160,138 @@ async function expectVerticalOrder( ) .toEqual(keys); } + +/** Persisted footer row key -> the testID the app shell renders that row with. */ +function shellFooterTestID(key: string): string { + if (key === "usage") return "sidebar-usage"; + const [, pluginId, itemId] = key.split(":"); + return `plugin-sidebar-footer-${pluginId}-${itemId}`; +} + +function shellFooterRow(page: Page, key: string): Locator { + return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first(); +} + +function footerSettingsRows(page: Page): Locator { + return page + .getByTestId("sidebar-nav-section-footer") + .locator('[data-testid^="sidebar-nav-item-"]'); +} + +export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise { + await expect + .poll(async () => + ( + await footerSettingsRows(page).evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ) + ).map((testID) => testID?.replace("sidebar-nav-item-", "")), + ) + .toEqual(keys); +} + +export async function moveFooterItemUp(page: Page, key: string): Promise { + await page + .getByTestId("sidebar-nav-section-footer") + .getByTestId(`sidebar-nav-move-up-${key}`) + .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 = 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"); +} + +export async function expectFooterItemHidden(page: Page, key: string): Promise { + await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0); +} + +const FOOTER_ICON_TEST_IDS = [ + "sidebar-add-project", + "sidebar-usage-icon", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-settings", +]; + +// Browser layout boxes include floating-point rounding, even for whole-pixel styles. +const FOOTER_GEOMETRY_TOLERANCE = 0.01; + +/** + * One line of same-size icons: Add project, Usage and Hosts together on the left, Help and + * Settings together at the end. + */ +export async function expectFooterIconRow(page: Page): Promise { + const boxes = ( + await Promise.all( + FOOTER_ICON_TEST_IDS.map((testID) => + page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), + ), + ) + ).map((box) => box!); + const [first] = boxes; + for (const box of boxes) { + expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2); + expect(Math.abs(box.width - first!.width)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE); + } + const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width)); + for (const index of [0, 1, 3]) { + expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE); + } + expect(gaps[2]).toBeGreaterThan(first!.width); +} + +export async function expectFooterSeparator(page: Page, shown: boolean): Promise { + await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS( + "border-top-width", + "1px", + ); + await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS( + "border-top-width", + "0px", + ); + const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible'); + await expect(separator).toHaveCount(shown ? 1 : 0); + expect( + await separator.evaluateAll((elements) => + elements.map((element) => getComputedStyle(element).borderBottomWidth), + ), + ).toEqual(shown ? ["1px"] : []); +} + +export async function hoverFooterAddProject(page: Page): Promise { + await page.locator('[data-testid="sidebar-add-project"]:visible').hover(); + const tooltip = page.getByTestId("sidebar-add-project-tooltip"); + await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible(); + await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible(); +} + +export async function footerScreenshot(page: Page, name: string): Promise { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + 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/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index dc65b63e08..19dbeecdd0 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -163,7 +163,6 @@ class StartupAssertions { await expect(hostRow).toBeVisible({ timeout: 15_000 }); await expect(hostRow).toContainText(input.label); await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible(); - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); await expect(this.page.getByTestId("sidebar-settings")).toBeVisible(); await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0); return this; 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 new file mode 100644 index 0000000000..234ec2bf1b --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -0,0 +1,134 @@ +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { UsageReport } from "@getpaseo/protocol/messages"; +import { expect, type Page } from "@playwright/test"; +import { expectPinnedUsage, pinRow, usageItem } from "./usage-sidebar-item"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +/** A real store-backed plugin on the worker's isolated daemon. */ +export async function installLoginUsage(initialReport: UsageReport) { + const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const store = path.join(directory, "report.json"); + const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const cleanup = async () => { + try { + await client.removePlugin("login-journey"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + await setReport(initialReport); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "login-journey", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import {readFile} from "node:fs/promises"; +import {z} from "zod"; +export default function contribute(server) { + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), + discover: async () => [{key: "account", input: {}}], + fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { setReport, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +export async function openUsage(page: Page) { + await page.goto("/usage"); +} + +export async function refreshLoginUsage(page: Page) { + await page.getByRole("button", { name: "Refresh Claude", exact: true }).click(); +} + +export async function hoverUsageWindow(page: Page, window: string) { + await page + .getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) }) + .getByTestId(/usage-pin-glyph-/) + .hover(); +} + +/** Exercises the built-in Codex adapter through the real plugin loader and daemon transport. */ +export async function installCodexWindowUsage(payload: unknown) { + const directory = await mkdtemp(path.join(tmpdir(), "usage-codex-windows-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const cleanup = async () => { + try { + await client.removePlugin("codex-window-qa"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + const source = path.resolve(__dirname, "../../../../../plugins/codex-usage-source"); + await cp(path.join(source, "server"), path.join(directory, "server"), { recursive: true }); + await cp(path.join(source, "shared"), path.join(directory, "shared"), { recursive: true }); + await writeFile( + path.join(directory, "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-codex" } }), + ); + await writeFile(path.join(directory, "payload.json"), JSON.stringify(payload)); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "codex-window-qa", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import {readFile} from "node:fs/promises"; +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: (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 () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +export async function expectCodexReportedWindows(page: Page, shape: "seven-day-only" | "Spark") { + await expectPinnedUsage(page, ["11% wk"]); + await expect(pinRow(page.locator("body"), "Codex", "Weekly")).toBeChecked(); + await expect(page.getByText("Session", { exact: true })).toHaveCount(0); + if (shape === "Spark") { + const fiveHour = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · 5-hour"); + const weekly = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · Weekly"); + await expect(fiveHour).not.toBeChecked(); + await expect(weekly).not.toBeChecked(); + await fiveHour.click(); + await expect(fiveHour).toBeChecked(); + await expect(usageItem(page)).toHaveAccessibleName( + /Codex Weekly 11% used, Codex GPT-5.3-Codex-Spark · 5-hour 0% used/, + ); + } +} diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 6a43d0fe30..86b74c6ea3 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -1,27 +1,44 @@ import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; -import { daemonWsRoutePattern } from "./daemon-port"; +import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; + +export interface UsageListRequest { + forceRefresh: boolean; + reportIds?: string[]; + agentId?: string; +} export interface UsageReportsFixture { - listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>; - agentRequests(): Array<{ agentId: string }>; + listRequests(): UsageListRequest[]; waitForListRequests(count: number): Promise; - waitForAgentRequests(count: number): Promise; } interface UsageReportsFixtureOptions { /** * Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that - * request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`). + * request; a function builds the response from the request when it arrives (e.g. a fresh + * `fetchedAt`, or a different answer to a forced refresh). */ - lists?: Array UsageListResponse)>; - /** Successive agent report IDs; the last one repeats. */ - agentReportIds?: Array; - /** Advertise `features.usageSources`. False simulates a host from before usage sources. */ - usageSources?: boolean; + lists?: Array UsageListResponse)>; + /** + * 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; @@ -42,7 +59,11 @@ function getSessionMessage(message: WebSocketMessage): Record | return envelope.message as Record; } -function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null { +function withUsageSupportFeature( + message: WebSocketMessage, + enabled: boolean, + providerUsageListOnly: boolean, +): string | null { const envelope = parseJson(message) as { type?: unknown; message?: { type?: unknown; payload?: Record }; @@ -61,7 +82,14 @@ function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): s ...envelope, message: { ...envelope.message, - payload: { ...payload, features: { ...features, usageSources: enabled } }, + payload: { + ...payload, + features: { + ...features, + usageSources: enabled && !providerUsageListOnly, + providerUsageList: enabled, + }, + }, }, }); } @@ -94,85 +122,121 @@ export async function installUsageReportsFixture( page: Page, options: UsageReportsFixtureOptions, ): Promise { - const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = []; - const agentRequests: Array<{ agentId: string }> = []; + const listRequests: UsageListRequest[] = []; const listCounter = createCounter(); - const agentCounter = createCounter(); - const usageSources = options.usageSources ?? true; + const usageSupported = options.usageSupported ?? true; + const isUsageSupported = () => + typeof usageSupported === "function" ? usageSupported() : usageSupported; + const providerUsageListOnly = options.providerUsageListOnly ?? false; + const requestType = providerUsageListOnly + ? "provider.usage.list.request" + : "usage.list_reports.request"; - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const route = + options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`); + await page.routeWebSocket(route, (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { const request = getSessionMessage(message); const requestId = request?.requestId; - if (request?.type === "usage.list_reports.request" && typeof requestId === "string") { - listRequests.push({ + if (request?.type === requestType && typeof requestId === "string") { + 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); - const response = typeof scripted === "function" ? scripted() : scripted; + const response = typeof scripted === "function" ? scripted(listRequest) : scripted; if ("error" in response) { ws.send( JSON.stringify({ type: "session", - message: { - type: "rpc_error", - payload: { - requestId, - requestType: "usage.list_reports.request", - 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; - ws.send( - JSON.stringify({ - type: "session", - message: { type: "usage.list_reports.response", payload: { requestId, reports } }, - }), - ); + if (!providerUsageListOnly) { + for (const report of reports) sendUpdate(report); + } + const reply = providerUsageListOnly + ? { + type: "provider.usage.list.response", + payload: { + requestId, + fetchedAt: new Date().toISOString(), + providers: reports.map((entry) => ({ + providerId: entry.sourceId, + displayName: entry.sourceLabel, + fetchedAt: entry.fetchedAt, + status: entry.report.status, + windows: entry.report.status === "available" ? entry.report.windows : [], + balances: entry.report.status === "available" ? entry.report.balances : undefined, + details: entry.report.status === "available" ? entry.report.details : undefined, + planLabel: + entry.report.status === "available" ? (entry.report.planLabel ?? null) : null, + error: entry.report.status === "error" ? entry.report.error : null, + })), + }, + } + : { type: "usage.list_reports.response", payload: { requestId, error: null } }; + ws.send(JSON.stringify({ type: "session", message: reply })); listCounter.increment(); return; } - if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") { - agentRequests.push({ - agentId: String(request.agentId), - }); - const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1); - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "agent.resolve_usage_report.response", - payload: { requestId, reportId }, - }, - }), - ); - agentCounter.increment(); - return; - } server.send(message); }); server.onMessage((message) => { const serverInfo = - typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null; + typeof message === "string" + ? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) + : null; ws.send(serverInfo ?? message); }); }); return { listRequests: () => [...listRequests], - agentRequests: () => [...agentRequests], waitForListRequests: (count) => listCounter.waitFor(count), - waitForAgentRequests: (count) => agentCounter.waitFor(count), }; } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts new file mode 100644 index 0000000000..7b012c171b --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -0,0 +1,242 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +export async function installTallUsageSource() { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const cleanup = async () => { + try { + await client.removePlugin("tall-usage"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "tall-usage", + requirements: pluginRequirements, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { z } from "zod"; +export default function contribute(server) { + server.registerUsageSource({ + id: "tall-usage", label: "Scrolling account", input: z.object({}), + discover: async () => [{key: "scrolling-account", input: {}}], + fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({ + id: String(i), label: "Window " + (i + 1), usedPct: 25, + })) }), + }); + return () => {}; +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins"); + +function sourceIcon(plugin: string): string { + return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8"); +} + +function inOneDay(): string { + return new Date(Date.now() + 24 * 60 * 60_000).toISOString(); +} + +/** Claude and Codex reports shaped like the built-in sources report them. */ +export function claudeAndCodexReports(): UsageReportEntry[] { + const fetchedAt = new Date().toISOString(); + return [ + { + id: "claude:default", + account: { label: "dev@example.com" }, + fetchedAt, + sourceId: "claude", + sourceLabel: "Claude", + icon: sourceIcon("claude-usage-source"), + report: { + status: "available", + planLabel: "Max", + windows: [ + { + id: "five_hour", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 31, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 54, + resetsAt: inOneDay(), + }, + ], + }, + }, + { + id: "codex:default", + account: { label: "dev@example.com" }, + fetchedAt, + sourceId: "codex", + sourceLabel: "Codex", + icon: sourceIcon("codex-usage-source"), + report: { + status: "available", + planLabel: "Pro", + windows: [ + { + id: "session", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 7, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 12, + resetsAt: inOneDay(), + }, + ], + }, + }, + ]; +} + +/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */ +function visible(page: Page, testID: string): Locator { + return page.locator(`[data-testid="${testID}"]:visible`).first(); +} + +/** The sidebar footer's Usage item: each summary window with data. */ +export function usageItem(page: Page): Locator { + return visible(page, "sidebar-usage"); +} + +/** The compact usage sheet the Usage item opens. */ +export function usageSheet(page: Page): Locator { + return visible(page, "usage-expanded"); +} + +export async function expectOnUsageScreen(page: Page): Promise { + await expect(page).toHaveURL(/\/usage$/); +} + +/** Each pinned window as it reads: its percent and short label, "31% 5h". */ +export async function expectPinnedUsage(page: Page, windows: string[]): Promise { + const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + await expect(pinned).toHaveText(windows); +} + +/** Without a summary window with data the footer has no Usage item, only the Usage icon. */ +export async function expectNoUsageItem(page: Page): Promise { + await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0); + await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible(); +} + +/** A window row, which is itself the pin toggle: "Claude", "Session". */ +export function pinRow(scope: Locator, source: string, window: string): Locator { + // The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h". + 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(); + await row.click(); + 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"); + if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click(); + await expect(visible(page, "usage-display-used")).toBeVisible(); +} + +export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { + await openUsageOptions(page); + await visible(page, `usage-display-${displayAs}`).click(); + await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute( + "aria-selected", + "true", + ); +} + +export async function refreshAllUsage(page: Page): Promise { + await visible(page, "usage-refresh-all").click(); +} + +/** Opens the compact sidebar drawer. */ +export async function openCompactSidebar(page: Page): Promise { + await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); +} + +/** On a phone the Usage screen has a back header instead of the sidebar menu. */ +export async function leaveUsageScreen(page: Page): Promise { + await page.getByRole("button", { name: "Back", exact: true }).first().click(); + await expect(page).not.toHaveURL(/\/usage$/); +} diff --git a/packages/app/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/metro.config.cjs b/packages/app/metro.config.cjs index e26fa04c32..c1fdbdef77 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -1,12 +1,10 @@ const { getDefaultConfig } = require("expo/metro-config"); const { resolve } = require("metro-resolver"); -const fs = require("fs"); const path = require("path"); const projectRoot = __dirname; const appNodeModulesRoot = path.resolve(projectRoot, "node_modules"); const appSrcRoot = path.resolve(projectRoot, "src"); -const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; const fdroidModuleOverrides = { "expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"), @@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform); } - const origin = context.originModulePath; - if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) { - const tsModuleName = moduleName.replace(/\.js$/, ".ts"); - const candidatePath = path.resolve(path.dirname(origin), tsModuleName); - if (fs.existsSync(candidatePath)) { - return resolveWithCustomWebOverlay(context, tsModuleName, platform); - } - } - return resolveWithCustomWebOverlay(context, moduleName, platform); }; diff --git a/packages/app/package.json b/packages/app/package.json index 1a293d00a0..a44656bc18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.10.0", + "version": "0.11.0-beta.3", "private": true, "main": "index.ts", "scripts": { @@ -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/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 5e625a9028..54160ebcb9 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -62,6 +62,8 @@ function installProbe( clientBundle, lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, }; } 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 cf682e7611..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"; @@ -45,6 +46,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; +import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet"; import { getIsElectronRuntime, HEADER_INNER_HEIGHT, @@ -112,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"; @@ -606,6 +608,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + @@ -970,11 +973,13 @@ function RootAppTree() { return ( - - - - - + + + + + + + ); diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index aae0073324..6aea5225d6 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import { useLocalSearchParams, useRouter, type Href } from "expo-router"; @@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { openPairScan } from "@/hosts/pair-scan-model"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { isWeb } from "@/constants/platform"; import { BackHeader } from "@/components/headers/back-header"; import { PairLinkModal } from "@/components/pair-link-modal"; +import { Button } from "@/components/ui/button"; const styles = StyleSheet.create((theme) => ({ container: { @@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 4, borderBottomRightRadius: 12, }, + scanAgainButton: { + marginTop: theme.spacing[6], + }, helperText: { marginTop: theme.spacing[6], color: theme.colors.foregroundMuted, @@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({ }, })); -function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null { - const raw = typeof result.data === "string" ? result.data.trim() : ""; - if (!raw) return null; - - if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) - return raw; - - return null; -} - export default function PairScanScreen() { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -129,9 +124,6 @@ export default function PairScanScreen() { const source = typeof params.source === "string" ? params.source : "settings"; const [permission, requestPermission] = useCameraPermissions(); - const [isPairing, setIsPairing] = useState(false); - const [scanError, setScanError] = useState(null); - const [passwordOfferUrl, setPasswordOfferUrl] = useState(null); const navigateToPairedHost = useCallback( (serverId: string) => { @@ -144,6 +136,19 @@ export default function PairScanScreen() { [router, source], ); + const [scan] = useState(() => + openPairScan({ + importConnectionLink: (link) => + getHostRuntimeStore().importConnectionLink( + link, + source === "onboarding" ? "hostRoot" : "hostSettings", + ), + onConnected: navigateToPairedHost, + }), + ); + const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); + useEffect(() => () => scan.close(), [scan]); + const closeToSource = useCallback(() => { try { router.back(); @@ -159,30 +164,12 @@ export default function PairScanScreen() { }, [permission, requestPermission]); const handleScan = useCallback( - (result: BarcodeScanningResult) => { - if (isPairing) return; - const offerUrl = extractOfferUrlFromScan(result); - if (!offerUrl) return; - - const store = getHostRuntimeStore(); - if (passwordOfferUrl) return; - setIsPairing(true); - setScanError(null); - void store - .importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings") - .then((outcome) => { - if (outcome.status === "connected") navigateToPairedHost(outcome.serverId); - else setPasswordOfferUrl(offerUrl); - return outcome; - }) - .catch((error) => setScanError(error instanceof Error ? error.message : String(error))) - .finally(() => setIsPairing(false)); - }, - [isPairing, navigateToPairedHost, passwordOfferUrl, source], + (result: BarcodeScanningResult) => + scan.scan(typeof result.data === "string" ? result.data : ""), + [scan], ); const handleRouterBack = useCallback(() => router.back(), [router]); - const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []); const savePasswordPairing = useCallback( ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), [navigateToPairedHost], @@ -240,24 +227,38 @@ export default function PairScanScreen() { barcodeScannerSettings={BARCODE_SCANNER_SETTINGS} onBarcodeScanned={handleScan} /> - + - {isPairing ? {t("pairing.scan.pairing")} : null} - {scanError ? {scanError} : null} + {scanState.status === "pairing" ? ( + {t("pairing.scan.pairing")} + ) : null} + {scanState.status === "stopped" && scanState.error ? ( + {scanState.error} + ) : null} + {scanState.status === "stopped" ? ( + + ) : null} )} 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/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index 50e61ed9fd..21ce492373 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -13,7 +13,7 @@ import { PlanModeIcon, ThinkingIcon, } from "@/agent-controls/icons"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcons } from "@/components/provider-icons"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { buildAgentControlContributions, @@ -88,6 +88,7 @@ export function useAgentControlCommandCenterActions(input: { const { t } = useTranslation(); const { controls } = input; const { features, models, modes, thinking } = controls; + const getProviderIcon = useProviderIcons(controls.serverId); const actions = useMemo( () => buildAgentControlContributions({ @@ -96,8 +97,7 @@ export function useAgentControlCommandCenterActions(input: { provider: controls.provider ?? null, labels: buildAgentControlContributionLabels(t), icons: { - provider: (provider) => - getCommandCenterIcon(getProviderIcon(provider, controls.serverId)), + provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)), thinking: getCommandCenterIcon(ThinkingIcon), planMode: getCommandCenterIcon(PlanModeIcon), mode: (modeId) => @@ -129,6 +129,7 @@ export function useAgentControlCommandCenterActions(input: { }, }), [ + getProviderIcon, controls.ownerKey, controls.provider, controls.providerDefinitions, diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index e909bf28e5..607f24c214 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - BottomSheetBackdrop, - BottomSheetFlatList, - type BottomSheetFlatListMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -718,12 +714,6 @@ export function CommandCenter() { layer: modalLayer, onKeyDown: handleWebOverlayKeyDown, }); - const backdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); if (showBottomSheet) { return ( @@ -735,7 +725,7 @@ export function CommandCenter() { enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={backdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundStyle={styles.sheetBackground} handleIndicatorStyle={styles.sheetHandle} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 2205409db1..4ceb43d917 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Modal, Platform, Pressable, Text, View } from "react-native"; +import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -13,13 +13,11 @@ import { useWebOverlayRegistration, } from "../lib/overlay-root"; import { - BottomSheetBackdrop, KEYBOARD_STATUS, useBottomSheetInternal, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -75,11 +73,15 @@ export interface SheetHeader { const SCROLL_CONTENT_GROW = { flexGrow: 1 }; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; +const NATIVE_DIALOG_SNAP_POINTS = ["100%"]; const styles = StyleSheet.create((theme) => ({ - nativeModalRoot: { + nativeDialogSurface: { flex: 1, }, + nativeDialogBackground: { + backgroundColor: "transparent", + }, desktopOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.55)", @@ -471,7 +473,7 @@ export interface AdaptiveModalSheetProps { contentStyle?: StyleProp; /** Size compact sheet content to the live snap height instead of its largest snap point. */ sizeContentToCurrentSnapPoint?: boolean; - /** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */ + /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */ contextBridge?: ContextBridge | null; } @@ -526,33 +528,26 @@ export function AdaptiveModalSheet({ () => ({ backgroundColor: theme.colors.palette.zinc[600] }), [theme.colors.palette.zinc], ); + useEffect(() => { + if (!isWeb && visible) { + // A newly opened sheet owns input. A keyboard belonging to the sheet below + // would cover controls in the new sheet, which has no focused input yet. + Keyboard.dismiss(); + } + }, [visible]); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, - isEnabled: isMobile, + isEnabled: isMobile || !isWeb, onClose, }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); - const nativeModalDismissNotifiedRef = useRef(!visible); const handleDismiss = useCallback(() => { handleSheetDismiss(); onDismiss?.(); }, [handleSheetDismiss, onDismiss]); - const notifyNativeModalDismiss = useCallback(() => { - if (nativeModalDismissNotifiedRef.current) { - return; - } - nativeModalDismissNotifiedRef.current = true; - onDismiss?.(); - }, [onDismiss]); - - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); const desktopCardStyle = useMemo( () => [ @@ -592,12 +587,6 @@ export function AdaptiveModalSheet({ onKeyDown: handleWebOverlayKeyDown, }); - useEffect(() => { - if (visible) { - nativeModalDismissNotifiedRef.current = false; - } - }, [visible]); - useEffect(() => { if (!isWeb || isMobile) return; if (visible) { @@ -615,12 +604,6 @@ export function AdaptiveModalSheet({ return () => window.clearTimeout(timeout); }, [visible, isMobile, onDismiss, shouldRenderWeb]); - useEffect(() => { - if (isWeb || isMobile || visible || Platform.OS !== "android") return; - const timeout = setTimeout(notifyNativeModalDismiss, 0); - return () => clearTimeout(timeout); - }, [visible, isMobile, notifyNativeModalDismiss]); - if (isMobile) { const sheetContent = ( <> @@ -658,7 +641,7 @@ export function AdaptiveModalSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} handleIndicatorStyle={handleIndicatorStyle} @@ -725,18 +708,28 @@ export function AdaptiveModalSheet({ } return ( - - {/* Android Modal opens a separate window outside the app's gesture root. */} - - {desktopContent} - - + {desktopContent} + ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index b388d76047..bf364f1040 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -294,11 +294,23 @@ export interface AddHostModalProps { } export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { + return ( + + ); +} + +function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); - const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } = - useHostMutations(); + const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations(); + const [linkPairing] = useState(() => beginLinkPairing()); const isMobile = useIsCompactFormFactor(); const [isSaving, setIsSaving] = useState(false); @@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const advancedTarget = useRef(new PairingTargetTracker("", true)); - const clearInput = useCallback(() => { - setHost(""); - setPort("6767"); - setUseTls(false); - setPassword(""); - setIsPasswordVisible(false); - setIsAdvancedOpen(false); - setAdvancedUri(""); - advancedTarget.current = new PairingTargetTracker("", true); - bumpInputResetKey(); - }, []); - const connectIcon = useMemo( () => , [theme.colors.accentForeground], @@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod ); const header = useMemo(() => ({ title: t("pairing.direct.title") }), [t]); + // Each open mounts a fresh form (see AddHostModal), so closing needs no reset. const handleClose = useCallback(() => { if (isSaving) return; - clearInput(); - setErrorMessage(""); onClose(); - }, [isSaving, clearInput, onClose]); + }, [isSaving, onClose]); const handleCancel = useCallback(() => { if (isSaving) return; - clearInput(); - setErrorMessage(""); (onCancel ?? onClose)(); - }, [isSaving, clearInput, onCancel, onClose]); + }, [isSaving, onCancel, onClose]); const handleSaveRelay = useCallback( async (relayUri: string) => { try { setIsSaving(true); setErrorMessage(""); - const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl( - relayUri, - password || undefined, - ); + const result = await linkPairing.submit(relayUri, password || undefined); + if (result.status === "cancelled") return; + const { profile, serverId, hostname } = result; const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); @@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod daemons, directConnectionLabels.invalidConnection, handleClose, + linkPairing, onSaved, password, - probeAndUpsertConnectionFromOfferUrl, ], ); 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/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 406b498bfe..7da36742ae 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -18,7 +18,7 @@ import { useTimeAgo } from "@/hooks/use-time-ago"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive, ChevronRight } from "lucide-react-native"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { HighlightedText } from "@/components/ui/highlighted-text"; @@ -186,7 +186,7 @@ function SessionRow({ const projectName = agent.projectPlacement?.projectName ?? ""; const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; const workspaceName = agent.projectPlacement?.workspaceName ?? ""; - const ProviderIcon = getProviderIcon(agent.provider, agent.serverId); + const ProviderIcon = useProviderIcon(agent.provider, agent.serverId); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const ranges = useMemo( () => ({ diff --git a/packages/app/src/components/context-window-details.tsx b/packages/app/src/components/context-window-details.tsx new file mode 100644 index 0000000000..dbeffa0ff3 --- /dev/null +++ b/packages/app/src/components/context-window-details.tsx @@ -0,0 +1,74 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AgentUsage } from "@/usage"; +import { formatTokenCount } from "./context-window-meter.utils"; + +interface ContextWindowDetailsProps { + serverId: string; + agentId: string; + percentage: number; + usedTokens: number; + maxTokens: number; + sessionCost: string | null; + /** The tooltip titles itself; the sheet's header carries the title instead. */ + showTitle: boolean; + /** Only the sheet can be pressed, so only its usage cards have a Refresh button. */ + refreshable: boolean; +} + +/** + * What the context window meter opens: how full the window is, then the usage of the agent's + * account. The wide tooltip and the compact sheet both render it. + */ +export function ContextWindowDetails({ + serverId, + agentId, + percentage, + usedTokens, + maxTokens, + sessionCost, + showTitle, + refreshable, +}: ContextWindowDetailsProps) { + const { t } = useTranslation(); + return ( + <> + + {showTitle ? {t("contextWindow.title")} : null} + {t("contextWindow.used", { percentage })} + + {t("contextWindow.tokens", { + used: formatTokenCount(usedTokens), + max: formatTokenCount(maxTokens), + })} + + {sessionCost ? ( + {t("contextWindow.sessionCost", { cost: sessionCost })} + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + gap: theme.spacing[1.5], + minWidth: 200, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.4, + }, + detail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, +})); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 88c46a0150..c89deaeea5 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,11 +1,17 @@ -import { Pressable, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { formatTokenCount } from "./context-window-meter.utils"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHostReportsUsage } from "@/usage"; +import { ContextWindowDetails } from "./context-window-details"; +import { ContextWindowSheet } from "./context-window-sheet"; interface ContextWindowMeterProps { + serverId: string; + agentId: string; maxTokens: number | null; usedTokens: number | null; totalCostUsd?: number | null; @@ -91,6 +97,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { } export function ContextWindowMeter({ + serverId, + agentId, maxTokens, usedTokens, totalCostUsd, @@ -100,6 +108,15 @@ export function ContextWindowMeter({ }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); + const { width } = useWindowDimensions(); + // Usage cards need a wider popover; without them it keeps the plain tooltip shape. + const showsUsage = useHostReportsUsage(serverId); + const popoverWidth = Math.min(360, width - 24); + // Compact screens open the details in a sheet, which can hold a pressable Refresh. + const isCompact = useIsCompactFormFactor(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const openSheet = useCallback(() => setIsSheetOpen(true), []); + const closeSheet = useCallback(() => setIsSheetOpen(false), []); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; const geometry = getMeterGeometry(showPercentage, glyphSize); @@ -142,6 +159,71 @@ export function ContextWindowMeter({ const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; + const ring = ( + + + + + ); + const percentageLabel = showPercentage ? ( + {`${roundedPercentage}%`} + ) : null; + const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage }); + + if (isCompact) { + return ( + <> + + {ring} + {percentageLabel} + + + + + + ); + } + return ( @@ -149,62 +231,30 @@ export function ContextWindowMeter({ style={containerStyle} testID="context-window-meter" accessibilityRole="image" - accessibilityLabel={t("contextWindow.accessibility", { - percentage: roundedPercentage, - })} + accessibilityLabel={accessibilityLabel} > - - - - - {showPercentage ? ( - {`${roundedPercentage}%`} - ) : null} + {ring} + {percentageLabel} - - - {t("contextWindow.title")} - - {t("contextWindow.used", { percentage: roundedPercentage })} - - - {t("contextWindow.tokens", { - used: formatTokenCount(usedTokens), - max: formatTokenCount(maxTokens), - })} - - {formattedSessionCost ? ( - - {t("contextWindow.sessionCost", { cost: formattedSessionCost })} - - ) : null} - + + ); @@ -237,22 +287,5 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, - tooltipContent: { - gap: theme.spacing[1.5], - minWidth: 200, - }, - tooltipTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - lineHeight: theme.fontSize.base * 1.4, - }, - tooltipDetail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, + popover: { padding: theme.spacing[4], gap: theme.spacing[4] }, })); diff --git a/packages/app/src/components/context-window-sheet.tsx b/packages/app/src/components/context-window-sheet.tsx new file mode 100644 index 0000000000..5dd454e048 --- /dev/null +++ b/packages/app/src/components/context-window-sheet.tsx @@ -0,0 +1,80 @@ +import { useMemo, type ReactNode } from "react"; +import { View, useWindowDimensions } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; + +// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not +// track, so the sheet is wrapped rather than reading the theme through a hook. +// See docs/unistyles.md. +const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ + backgroundStyle: { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +/** + * The context window meter's details on compact screens, in a sheet as tall as its content. + * Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once + * the sheet closes. + */ +export function ContextWindowSheet({ + open, + onClose, + children, +}: { + open: boolean; + onClose: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]); + const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ + visible: open, + onClose, + }); + + return ( + + + + + {/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */} + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + gap: theme.spacing[4], + }, +})); diff --git a/packages/app/src/components/hosts/host-filter.tsx b/packages/app/src/components/hosts/host-filter.tsx index ffdb6a2130..d073dc943d 100644 --- a/packages/app/src/components/hosts/host-filter.tsx +++ b/packages/app/src/components/hosts/host-filter.tsx @@ -2,13 +2,13 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, Server } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { HostProfile } from "@/types/host-connection"; import type { Theme } from "@/styles/theme"; import { ALL_HOSTS_OPTION_ID, getHostPickerLabel, HostPicker, HostStatusDotSlot, + type HostPickerHost, } from "@/components/hosts/host-picker"; const ThemedServer = withUnistyles(Server); @@ -16,7 +16,7 @@ const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); export interface HostFilterProps { - hosts: HostProfile[]; + hosts: HostPickerHost[]; selectedHost: string; onSelectHost: (serverId: string) => void; /** @@ -29,9 +29,9 @@ export interface HostFilterProps { } /** - * The "All hosts / " filter pill shared by the History and Schedules - * screens: an anchored HostPicker with `includeAllHost`, hidden by the caller - * when only one host exists. Copies the History layout exactly. + * The "All hosts / " host pill shared by History, Schedules, Usage, the label manager and + * plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without + * `includeAllHost` it picks the one host a surface shows. */ export function HostFilter({ hosts, diff --git a/packages/app/src/components/hosts/host-picker.tsx b/packages/app/src/components/hosts/host-picker.tsx index 9bc8955e64..ced0be3d55 100644 --- a/packages/app/src/components/hosts/host-picker.tsx +++ b/packages/app/src/components/hosts/host-picker.tsx @@ -24,7 +24,7 @@ export { const SEARCHABLE_THRESHOLD = 10; type RenderHostOption = NonNullable; -interface HostPickerHost { +export interface HostPickerHost { serverId: string; label: string; } diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 65e32d752c..35c475e824 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -63,7 +63,8 @@ vi.mock("@/constants/layout", () => ({ })); vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => () => null, + useProviderIcon: () => () => null, + useProviderIcons: () => () => () => null, })); vi.mock("lucide-react-native", () => { diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index c6d13d1a28..3be15c0892 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -13,7 +13,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; import { useTimeAgo } from "@/hooks/use-time-ago"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -309,7 +309,7 @@ function ImportSessionSheetRow({ const { t } = useTranslation(); const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); - const ProviderIcon = getProviderIcon(entry.providerId, serverId); + const ProviderIcon = useProviderIcon(entry.providerId, serverId); const accessibilityState = useMemo( () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), [disabled], @@ -559,15 +559,17 @@ export function ImportSessionSheet({ setIsFilterOpen(false); }, []); + const getProviderIcon = useProviderIcons(serverId); + const SelectedProviderIcon = useProviderIcon(selectedProvider, serverId); const filterOptionIcons = useMemo(() => { const map = new Map(); map.set(ALL_FILTER_VALUE, ); for (const provider of filterProviders) { - const ProviderIcon = getProviderIcon(provider, serverId); + const ProviderIcon = getProviderIcon(provider); map.set(provider, ); } return map; - }, [filterProviders, serverId, theme.colors.foregroundMuted]); + }, [filterProviders, getProviderIcon, theme.colors.foregroundMuted]); const renderFilterOption = useCallback( ({ @@ -746,10 +748,7 @@ export function ImportSessionSheet({ {selectedProvider === ALL_FILTER_VALUE ? ( ) : ( - (() => { - const ProviderIcon = getProviderIcon(selectedProvider, serverId); - return ; - })() + )} {selectedProviderLabel} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4902a3ae93..a6def126b1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; +import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -8,7 +8,6 @@ import { Text, useWindowDimensions, View, - type PressableStateCallbackType, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -23,9 +22,11 @@ import { } from "@/components/sidebar-resize-handle-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; +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"; @@ -43,16 +44,16 @@ import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels" import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model"; import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useHosts } from "@/runtime/host-runtime"; +import { PluginSidebarItem } from "@/plugins/sidebar-items"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { - buildSettingsAddHostRoute, - buildSettingsRoute, - buildUsageRoute, -} from "@/utils/host-routes"; +import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; +import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -89,9 +90,9 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; closeSidebar: string; } @@ -196,9 +197,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), hosts: t("sidebar.actions.hosts"), - importSession: t("importSession.title"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), + usage: t(builtinSidebarNavLabelKey("usage")), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], @@ -275,7 +276,7 @@ function FooterIconButton({ testID, label, icon: Icon, - iconSize, + iconSizeAdjustment = 0, shortcutKeys, theme, }: { @@ -283,17 +284,21 @@ function FooterIconButton({ testID: string; label: string; icon: typeof FolderPlus; - iconSize?: number; + /** Only for a glyph that reads larger than the others at the same size. */ + iconSizeAdjustment?: number; shortcutKeys?: ReturnType; theme: SidebarTheme; buttonRef?: RefObject; }) { + const isCompact = useIsCompactFormFactor(); + const iconSize = isCompact ? theme.iconSize.xl : theme.iconSize.md; + return ( {({ hovered }) => ( )} - - - - - ); -} - -function footerAddProjectButtonStyle({ - hovered, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered]; -} - -function FooterAddProjectButton({ - onPress, - label, - shortcutKeys, - theme, -}: { - onPress: () => void; - label: string; - shortcutKeys: ReturnType; - theme: SidebarTheme; -}) { - return ( - - - - {({ hovered }) => { - const isHovered = Boolean(hovered); - return ( - <> - - - {label} - - - ); - }} - - - + @@ -423,32 +370,14 @@ function SidebarHostPicker({ testID="sidebar-hosts-trigger" label={label} icon={Server} - iconSize={theme.iconSize.sm} + // Server's two boxes fill more of the square than the other glyphs. + iconSizeAdjustment={-1} theme={theme} /> ); } -function SidebarUsageButton({ theme }: { theme: SidebarTheme }) { - const { t } = useTranslation(); - const isCompactLayout = useIsCompactFormFactor(); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const handlePress = useCallback(() => { - if (isCompactLayout) showMobileAgent(); - router.push(buildUsageRoute()); - }, [isCompactLayout, showMobileAgent]); - return ( - - ); -} - function IconTooltipContent({ label, shortcutKeys, @@ -467,52 +396,57 @@ function IconTooltipContent({ function SidebarFooter({ theme, handleOpenProject, - handleImportSession, handleSettings, labels, handleAddHost, handleOpenHostSettings, + onBeforeNavigate, }: { theme: SidebarTheme; handleOpenProject: () => void; - handleImportSession: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; + onBeforeNavigate?: () => void; }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); + const openUsageScreen = useOpenUsageScreen(); + // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. return ( - - - + + + + + - - + void }) { + const { items } = useSidebarNavItems("footer"); + const hasUsageSummary = useHasUsageSummary(); + const rowsRef = useRef(null); + const visibleItems = items.filter( + (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + ); + if (visibleItems.length === 0) return null; + return ( + <> + + {visibleItems.map((item) => + item.kind === "plugin" ? ( + + ) : ( + + ), + )} + + + + ); +} + function MobileSidebar({ active, theme, @@ -580,7 +548,11 @@ function MobileSidebar({ - + @@ -806,7 +778,6 @@ function DesktopSidebar({ ({ right: 0, zIndex: 2, alignItems: "flex-end", - pointerEvents: "box-none", }, mobileCloseButton: { // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. @@ -953,53 +923,35 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - sidebarFooter: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], + footerContainer: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, - footerIconRow: { + // Buttons sit edge to edge; their own inset spaces the glyphs. + sidebarFooter: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - footerAddProjectButton: { - minWidth: 0, - minHeight: 32, - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[1.5], paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[1.5], }, - footerAddProjectButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, + // Pushes Help and Settings to the end of the icon line. + footerSpacer: { + flex: 1, }, - footerAddProjectLabel: { - minWidth: 0, - flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, + // Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows. + footerRows: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1.5], + gap: 2, }, - footerAddProjectLabelHovered: { - color: theme.colors.foreground, - }, - 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/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 9d0c8f8bc3..17924b233d 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -39,7 +39,7 @@ import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { @@ -223,7 +223,7 @@ export function ModelProviderGlyph({ size: number; tone?: ProviderGlyphTone; }) { - const Icon = getProviderIcon(provider, serverId); + const Icon = useProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; return ; diff --git a/packages/app/src/components/page-layout/index.tsx b/packages/app/src/components/page-layout/index.tsx index f210369184..e0c46b695a 100644 --- a/packages/app/src/components/page-layout/index.tsx +++ b/packages/app/src/components/page-layout/index.tsx @@ -12,6 +12,8 @@ interface PageLayoutProps { onBack?: () => void; testID?: string; titleTestID?: string; + /** Controls for the whole page: right of the title on desktop, in the back header on compact. */ + actions?: ReactNode; children: ReactNode; } @@ -20,7 +22,14 @@ interface PageLayoutProps { * document heading inside the page and the header only keeps the titlebar drag * region and window controls; on compact the title moves into a back header. */ -export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) { +export function PageLayout({ + title, + onBack, + testID, + titleTestID, + actions, + children, +}: PageLayoutProps) { const isCompact = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); @@ -28,14 +37,19 @@ export function PageLayout({ title, onBack, testID, titleTestID, children }: Pag return ( - {isCompact ? : } + {isCompact ? ( + + ) : ( + + )} {showTitle ? ( - + {title} + {actions} ) : null} {children} @@ -60,10 +74,16 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: 720, alignSelf: "center", }, + titleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[4], + marginBottom: theme.spacing[6], + }, title: { // Line up with the section titles, which sit inset from their cards. marginLeft: theme.spacing[1], - marginBottom: theme.spacing[6], fontSize: theme.fontSize["4xl"], fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index d68f72bedd..8ed9cefba9 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Link } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; -import { useHosts, useHostMutations } from "@/runtime/host-runtime"; +import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime"; import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; @@ -62,8 +62,8 @@ const styles = StyleSheet.create((theme) => ({ export interface PairLinkModalProps { visible: boolean; - initialUrl?: string; - initialPasswordRequired?: boolean; + /** Continues a confirmed pairing whose host asked for a password. */ + passwordRequired?: PasswordRequiredPairing; onClose: () => void; onCancel?: () => void; onSaved?: (result: { @@ -76,18 +76,16 @@ export interface PairLinkModalProps { export function PairLinkModal({ visible, - initialUrl, - initialPasswordRequired = false, + passwordRequired, onClose, onCancel, onSaved, }: PairLinkModalProps) { return ( passwordRequired?.pairing ?? beginLinkPairing()); + const initialUrl = passwordRequired?.link; const isMobile = useIsCompactFormFactor(); const offerUrlRef = useRef(initialUrl ?? ""); @@ -115,7 +114,7 @@ function PairLinkModalContent({ const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [password, setPassword] = useState(""); - const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired); + const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined); const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0); const clearInput = useCallback(() => { @@ -162,10 +161,9 @@ function PairLinkModalContent({ try { setIsSaving(true); setErrorMessage(""); - const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl( - raw, - password || undefined, - ); + const result = await pairing.submit(raw, password || undefined); + if (result.status === "cancelled") return; + const { profile, serverId, hostname } = result; const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); @@ -184,16 +182,7 @@ function PairLinkModalContent({ setIsSaving(false); } }, - [ - daemons, - handleClose, - isMobile, - isSaving, - onSaved, - password, - t, - probeAndUpsertConnectionFromOfferUrl, - ], + [daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing], ); const handleChangeOfferUrl = useCallback((next: string) => { diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index de2f8c3798..65c75166a5 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -5,7 +5,7 @@ import { TERMINAL_PROFILE_ICON_NAMES, } from "@getpaseo/protocol/provider-icon-names"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; -import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name"; +import { resolveProviderIconName } from "./provider-icon-name"; describe("resolveProviderIconName", () => { it("returns the built-in identifier for known provider ids", () => { @@ -29,43 +29,7 @@ describe("resolveProviderIconName", () => { it("resolves a snapshot SVG for a custom provider", () => { const svg = ''; - replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]); - - expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg }); - }); - - it("replaces each host snapshot without leaking icons across hosts", () => { - const secondSvg = ""; - replaceProviderSnapshotIcons("server-1", [ - { provider: "removed-provider", iconSvg: "" }, - { provider: "snapshot-provider", iconSvg: "" }, - { provider: "claude", iconSvg: "" }, - ]); - replaceProviderSnapshotIcons("server-2", [ - { provider: "snapshot-provider", iconSvg: secondSvg }, - ]); - - expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ - kind: "svg", - svg: "", - }); - expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ - kind: "svg", - svg: secondSvg, - }); - expect(resolveProviderIconName("claude", "server-1")).toEqual({ - kind: "builtin", - id: "claude", - }); - - replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]); - - expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" }); - expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" }); - expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ - kind: "svg", - svg: secondSvg, - }); + expect(resolveProviderIconName("snapshot-provider", svg)).toEqual({ kind: "svg", svg }); }); }); diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 699865fd01..38d4344cd1 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -2,7 +2,6 @@ import { BUILTIN_PROVIDER_ICON_NAMES, KNOWN_PROVIDER_ICON_NAMES, } from "@getpaseo/protocol/provider-icon-names"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; export type ProviderIconName = | { kind: "builtin"; id: string } @@ -12,31 +11,10 @@ export type ProviderIconName = const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES); const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES); -const providerSnapshotIconSvgsByServer = new Map>(); - -export function replaceProviderSnapshotIcons( - serverId: string, - entries: readonly Pick[], -): void { - const icons = new Map(); - for (const entry of entries) { - if (entry.iconSvg) { - icons.set(entry.provider, entry.iconSvg); - } - } - providerSnapshotIconSvgsByServer.set(serverId, icons); -} - -export function resolveProviderIconName( - provider: string, - serverId?: string | null, -): ProviderIconName { +export function resolveProviderIconName(provider: string, iconSvg?: string): ProviderIconName { if (BUILTIN_PROVIDER_IDS.has(provider)) { return { kind: "builtin", id: provider }; } - const iconSvg = serverId - ? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider) - : undefined; if (iconSvg) { return { kind: "svg", svg: iconSvg }; } diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts index 5235218875..025ff14bef 100644 --- a/packages/app/src/components/provider-icons.test.ts +++ b/packages/app/src/components/provider-icons.test.ts @@ -1,8 +1,12 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; import { Bot } from "lucide-react-native"; import { SvgXml } from "react-native-svg"; -import { describe, expect, it } from "vitest"; -import { replaceProviderSnapshotIcons } from "./provider-icon-name"; -import { getProviderIcon, type ProviderIconComponent } from "./provider-icons"; +import { afterEach, describe, expect, it } from "vitest"; +import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; +import { useProviderIcon, useProviderIcons, type ProviderIconComponent } from "./provider-icons"; + +afterEach(cleanup); function renderIcon(Component: ProviderIconComponent) { if (typeof Component !== "function") throw new Error("Expected a function component"); @@ -12,12 +16,14 @@ function renderIcon(Component: ProviderIconComponent) { }); } -describe("getProviderIcon", () => { +describe("useProviderIcon", () => { it("renders registered snapshot SVG metadata with the requested size and color", () => { const svg = ''; replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]); - const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1")); + const rendered = renderIcon( + renderHook(() => useProviderIcon("rendered-provider", "server-1")).result.current, + ); expect(rendered).toMatchObject({ type: SvgXml, @@ -28,6 +34,53 @@ describe("getProviderIcon", () => { it("uses the normal Bot fallback without snapshot SVG metadata", () => { replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]); - expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot); + expect(renderHook(() => useProviderIcon("plain-provider", "server-1")).result.current).toBe( + Bot, + ); }); }); + +it("updates a mounted consumer when the plugin snapshot arrives after mount", () => { + const serverId = "cold-reload-host"; + const provider = "plugin-provider"; + const svg = ''; + replaceProviderSnapshotIcons(serverId, []); + const { result, unmount } = renderHook(() => useProviderIcon(provider, serverId)); + expect(result.current).toBe(Bot); + act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }])); + expect(renderIcon(result.current)).toMatchObject({ + type: SvgXml, + props: { xml: svg, width: 18, height: 18, color: "#123456" }, + }); + unmount(); +}); + +it("keeps host SVGs isolated, updates replacements, and clears removed icons", () => { + const provider = "shared-plugin-provider"; + const firstSvg = ''; + const secondSvg = ''; + replaceProviderSnapshotIcons("first-host", [{ provider, iconSvg: firstSvg }]); + replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: secondSvg }]); + const first = renderHook(() => useProviderIcon(provider, "first-host")); + const second = renderHook(() => useProviderIcon(provider, "second-host")); + const firstIcon = first.result.current; + expect(renderIcon(firstIcon)).toMatchObject({ props: { xml: firstSvg } }); + expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: secondSvg } }); + act(() => replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: firstSvg }])); + expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: firstSvg } }); + expect(first.result.current).toBe(firstIcon); + act(() => replaceProviderSnapshotIcons("second-host", [])); + expect(second.result.current).toBe(Bot); + expect(first.result.current).toBe(firstIcon); +}); + +it("updates collection consumers when their host snapshot arrives", () => { + const serverId = "collection-host"; + const provider = "collection-plugin-provider"; + const svg = ''; + replaceProviderSnapshotIcons(serverId, []); + const { result } = renderHook(() => useProviderIcons(serverId)); + expect(result.current(provider)).toBe(Bot); + act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }])); + expect(renderIcon(result.current(provider))).toMatchObject({ props: { xml: svg } }); +}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index a66eaafa1d..fec4003705 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,5 +1,7 @@ +import { useStore } from "zustand"; +import { providerSnapshotIcons } from "@/data/provider-icons"; import { Bot, PackagePlus } from "lucide-react-native"; -import { createElement, type ComponentType } from "react"; +import { createElement, useCallback, type ComponentType } from "react"; import { SvgXml } from "react-native-svg"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; @@ -34,7 +36,7 @@ const CATALOG_ICON_SVGS = new Map( ); const catalogIconComponents = new Map(); -const snapshotIconComponents = new Map(); +const snapshotIconComponents = new Map(); /** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */ export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) { @@ -63,15 +65,15 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent { } function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent { - const cached = snapshotIconComponents.get(provider); - if (cached?.svg === svg) return cached.component; + const cached = snapshotIconComponents.get(svg); + if (cached) return cached; const component = createSvgIcon(provider, svg); - snapshotIconComponents.set(provider, { svg, component }); + snapshotIconComponents.set(svg, component); return component; } -export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent { - const name = resolveProviderIconName(provider, serverId); +function resolveProviderIcon(provider: string, svg?: string): ProviderIconComponent { + const name = resolveProviderIconName(provider, svg); if (name.kind === "builtin") { return BUILTIN_PROVIDER_ICONS[name.id]; } @@ -79,7 +81,25 @@ export function getProviderIcon(provider: string, serverId?: string | null): Pro return getCatalogProviderIcon(name.id); } if (name.kind === "svg") { - return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg); + return getSnapshotProviderIcon(provider, name.svg); } return Bot; } + +export function useProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent { + const svg = useStore(providerSnapshotIcons, (state) => + serverId ? state.get(serverId)?.get(provider) : undefined, + ); + return resolveProviderIcon(provider, svg); +} + +/** Subscribe once for consumers that render a collection of providers. */ +export function useProviderIcons(serverId?: string | null) { + const icons = useStore(providerSnapshotIcons, (state) => + serverId ? state.get(serverId) : undefined, + ); + return useCallback( + (provider: string) => resolveProviderIcon(provider, icons?.get(provider)), + [icons], + ); +} diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 2a3cd97c10..230a41e2e9 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -24,7 +24,7 @@ import { HostStatusDotSlot } from "@/components/hosts/host-picker"; import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { Switch } from "@/components/ui/switch"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { CadenceEditor } from "@/components/schedules/cadence-editor"; import { SelectField, @@ -1026,10 +1026,10 @@ function ProviderGlyph({ provider: string | null; serverId: string | null; }): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider, serverId); return ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index 96099a0ec7..2af4b33428 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { StatusBadge } from "@/components/ui/status-badge"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; @@ -144,7 +144,7 @@ function ScheduleMeta({ } /** Small provider glyph. Reads the icon color off a StyleSheet object so the - * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ + * dynamic component (useProviderIcon) stays compliant without useUnistyles. */ function ProviderGlyph({ provider, serverId, @@ -152,10 +152,10 @@ function ProviderGlyph({ provider: string | null; serverId: string; }): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider, serverId); return ; } diff --git a/packages/app/src/components/settings/collapsible-row.tsx b/packages/app/src/components/settings/collapsible-row.tsx new file mode 100644 index 0000000000..219c19776c --- /dev/null +++ b/packages/app/src/components/settings/collapsible-row.tsx @@ -0,0 +1,102 @@ +import { ChevronRight } from "lucide-react-native"; +import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { isWeb } from "@/constants/platform"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +interface SettingsCollapsibleRowProps { + label: string; + defaultExpanded?: boolean; + testID?: string; + /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + children: ReactNode; +} + +/** + * A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a + * trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any + * other row. + */ +export function SettingsCollapsibleRow({ + label, + defaultExpanded = false, + testID, + children, +}: SettingsCollapsibleRowProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + // React Native Web does not map `accessibilityState.expanded` to `aria-expanded`. + const webExpandedProps = useMemo( + () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), + [expanded], + ); + const headerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + settingsStyles.row, + hovered && styles.headerHovered, + pressed && styles.headerPressed, + ], + [], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + + return ( + + + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + <> + + {label} + + + + + + )} + + {expanded + ? Children.toArray(children).map((child, index) => ( + + {child} + + )) + : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerHovered: { + backgroundColor: theme.colors.surface2, + }, + headerPressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 00ac273bf0..f31a0e7b38 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -26,6 +26,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; export { SettingsGroup } from "./headings/settings-group"; export { SettingsSection } from "./headings/settings-section"; +export { SettingsCollapsibleRow } from "./collapsible-row"; interface AppSettingsRowProps extends Omit { hint?: ReactNode; diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index e30604ac9a..4519f6d4ff 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,7 +1,13 @@ -import { useCallback, useMemo } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { + useCallback, + useMemo, + useState, + type ComponentType, + type ReactNode, + type Ref, +} from "react"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { LucideIcon } from "lucide-react-native"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; @@ -11,10 +17,12 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -type SidebarHeaderRowVariant = "header" | "compact"; +type SidebarHeaderRowVariant = "header" | "compact" | "inline"; + +export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; interface SidebarHeaderRowProps { - icon: LucideIcon; + icon: SidebarRowIcon | null; label: string; onPress: () => void; isActive?: boolean; @@ -26,9 +34,18 @@ interface SidebarHeaderRowProps { * the lone header at the top of a sidebar (settings "Back to workspace"). * "compact": a row with no separator, for entries that * sit in a header group whose wrapper owns the single divider. + * "inline": a full-width row with no inset, for a row inside a padded container such as the + * sidebar footer. */ variant?: SidebarHeaderRowVariant; + /** Shown in the right slot while the row is hovered, when `trailing` is not set. */ shortcutKeys?: ShortcutKey[][] | null; + /** + * The right slot. A sibling of the row's button, never inside it (web cannot nest buttons). A + * press on the slot presses the row; a button inside it presses on its own. + */ + trailing?: ReactNode; + rowRef?: Ref; } export function SidebarHeaderRow({ @@ -41,71 +58,60 @@ export function SidebarHeaderRow({ accessibilityLabel, variant = "header", shortcutKeys = null, + trailing, + rowRef, }: SidebarHeaderRowProps) { - const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]); + const isHighlighted = isHovered || isActive; + const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm; - const containerStyle = useMemo( - () => (variant === "compact" ? styles.containerCompact : styles.container), - [variant], - ); - - const buttonStyle = useCallback( - ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.button, - (Boolean(hovered) || isActive) && styles.buttonHovered, - ], - [isActive], - ); - - const renderChildren = useCallback( - (state: PressableStateCallbackType & { hovered?: boolean }) => { - const isHighlighted = Boolean(state.hovered) || isActive; - return ( - <> - - - {shortcutKeys && Boolean(state.hovered) ? ( - - ) : null} - - ); - }, - [ThemedIcon, isActive, label, shortcutKeys, variant], - ); + let right = trailing ?? null; + if (right === null && shortcutKeys && isHovered) { + right = ; + } return ( - - + - {renderChildren} - + + {ThemedIcon ? ( + + ) : ( + + )} + {label} + + {right === null ? null : ( + + {right} + + )} + ); } -function SidebarHeaderRowLabel({ - label, - isHighlighted, -}: { - label: string; - isHighlighted: boolean; -}) { - const labelStyle = useMemo( - () => [styles.label, isHighlighted && styles.labelHighlighted], - [isHighlighted], - ); - return {label}; -} +const SELECTED_STATE = { selected: true } as const; const styles = StyleSheet.create((theme) => ({ container: { @@ -124,23 +130,38 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", userSelect: "none", }, + containerInline: { + width: "100%", + justifyContent: "center", + userSelect: "none", + }, + row: { + flexDirection: "row", + alignItems: "center", + // Same row geometry as the settings sidebar items. Shorter than the header + // strip so the hover highlight clears the strip's bottom separator. + minHeight: 28, + borderRadius: theme.borderRadius.lg, + }, + rowHighlighted: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, button: { + flex: 1, + minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - // Same row geometry as the settings sidebar items. Shorter than the header - // strip so the hover highlight clears the strip's bottom separator. minHeight: 28, paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical // edge with the list below. paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - }, - buttonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, }, + iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, + iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, label: { + flexShrink: 1, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, @@ -148,7 +169,21 @@ const styles = StyleSheet.create((theme) => ({ labelHighlighted: { color: theme.colors.foreground, }, - shortcut: { - marginLeft: "auto", + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingRight: theme.spacing[2], }, })); + +function getContainerStyle(variant: SidebarHeaderRowVariant) { + switch (variant) { + case "header": + return styles.container; + case "compact": + return styles.containerCompact; + case "inline": + return styles.containerInline; + } +} 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/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 258dc19468..425280207e 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -1,11 +1,11 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, History, Plus, Search } from "lucide-react-native"; -import { memo, useCallback, useMemo, type ComponentType } from "react"; +import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { PluginSidebarItemRow } from "@/plugins/sidebar-items"; +import { PluginSidebarItem } from "@/plugins/sidebar-items"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; import { @@ -38,19 +38,22 @@ interface SidebarNavRowsProps extends SidebarNavRowProps { * wrapper — when every item is hidden. */ export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { - const { items } = useSidebarNavItems(); + const { items } = useSidebarNavItems("header"); const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); + const groupRef = useRef(null); if (visibleItems.length === 0) return null; return ( - + {visibleItems.map((item) => { if (item.kind === "plugin") { return ( - ); diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx new file mode 100644 index 0000000000..ec13605d08 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -0,0 +1,98 @@ +import { useCallback, type ReactNode, type RefObject } from "react"; +import type { View } from "react-native"; +import { MenuRoot, useMenuContext } from "@/components/ui/menu"; +import { PluginPopoverSurface } from "@/plugins/popover"; +import type { SidebarSection } from "@/sidebar-nav/model"; + +/** + * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on + * compact ones. Plugin items and the built-in Usage item open it the same way: + * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, + * and `SidebarPopoverSurface` for the body. + */ +export function SidebarPopoverRoot({ + open, + onOpenChange, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function assignRef(ref: RefObject, node: View | null) { + Object.assign(ref, { current: node }); +} + +/** + * Where the popover anchors. `anchorTo` moves it to an element, such as the row just pressed; + * `offerAnchor` sets it only when nothing is anchored; `releaseAnchor` drops an element that is + * going away; `anchorToFallback` covers an item that anchored nothing. + */ +export function useSidebarPopoverAnchor(name: string): { + anchorTo: (node: View | null) => void; + offerAnchor: (node: View | null) => void; + releaseAnchor: (node: View | null) => void; + anchorToFallback: (fallback: RefObject) => void; +} { + const { triggerRef } = useMenuContext(name); + const anchorTo = useCallback((node: View | null) => assignRef(triggerRef, node), [triggerRef]); + const offerAnchor = useCallback( + (node: View | null) => { + if (!triggerRef.current) assignRef(triggerRef, node); + }, + [triggerRef], + ); + const releaseAnchor = useCallback( + (node: View | null) => { + if (triggerRef.current === node) assignRef(triggerRef, null); + }, + [triggerRef], + ); + const anchorToFallback = useCallback( + (fallback: RefObject) => offerAnchor(fallback.current), + [offerAnchor], + ); + return { anchorTo, offerAnchor, releaseAnchor, anchorToFallback }; +} + +const PLACEMENT = { + header: { side: "right", align: "start" }, + footer: { side: "top", align: "start" }, +} as const satisfies Record; + +export function SidebarPopoverSurface({ + section, + title, + sheetTrailing, + testID, + children, +}: { + section: SidebarSection; + /** The bottom sheet's title. */ + title: string; + /** Controls on the right of the bottom sheet's title. */ + sheetTrailing?: ReactNode; + testID: string; + children: ReactNode; +}) { + const placement = PLACEMENT[section]; + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx index 864e56361f..30310d84ad 100644 --- a/packages/app/src/components/sidebar/sidebar-separator.tsx +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles"; * padded container so the line runs the full sidebar width — matching the * separator beneath the workspace sidebar's "New agent" header. */ -export function SidebarSeparator() { - return ; +export function SidebarSeparator({ testID }: { testID?: string } = {}) { + return ; } const styles = StyleSheet.create((theme) => ({ 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/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 9249463197..31c36b607c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -4,7 +4,13 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Clipboard from "expo-clipboard"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; @@ -31,9 +37,10 @@ import { } from "@/terminal/runtime/terminal-key-dispatch"; import { getTerminalVirtualKeyboardControlId, + resolveTerminalVirtualKeyboardRows, + shouldShowTerminalVirtualKeyBar, shouldShowTerminalFloatingCopyAction, shouldShowTerminalPasteAction, - TERMINAL_VIRTUAL_KEYBOARD_ROWS, type TerminalVirtualKeyboardControl, } from "@/terminal/runtime/terminal-virtual-keyboard"; import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; @@ -93,6 +100,7 @@ interface TerminalPaneProps { const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const TERMINAL_RESIZE_DEBOUNCE_MS = 100; +const TERMINAL_KEY_BAR_MAX_WIDTH = 880; const MODIFIER_LABELS = { ctrl: "Ctrl", @@ -138,8 +146,16 @@ function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) { () => [styles.keyButtonText, active && styles.keyButtonTextActive], [active], ); + const accessibilityState = useMemo(() => ({ selected: active }), [active]); return ( - + {MODIFIER_LABELS[modifier]} ); @@ -162,7 +178,13 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps [], ); return ( - + {label} ); @@ -222,12 +244,18 @@ export function TerminalPane({ return trimmed.length > 0 ? trimmed : undefined; }, [settings.monoFontFamily]); const isMobile = useIsCompactFormFactor(); + const showVirtualKeyBar = shouldShowTerminalVirtualKeyBar({ isNative, isCompact: isMobile }); + const [keyBarWidth, setKeyBarWidth] = useState(0); + const virtualKeyboardRows = resolveTerminalVirtualKeyboardRows({ + isCompact: isMobile, + availableWidth: keyBarWidth, + }); const mobileView = usePanelStore((state) => state.mobilePanel.target); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const swipeGesturesEnabled = isMobile; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", - enabled: isMobile, + enabled: isNative || isMobile, }); const [keyboardInset, setKeyboardInset] = useState(0); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); @@ -292,7 +320,7 @@ export function TerminalPane({ }, [terminalId]); const refreshClipboardAvailability = useCallback(async () => { - if (!isMobile) { + if (!showVirtualKeyBar) { setHasClipboardText(false); return; } @@ -302,7 +330,7 @@ export function TerminalPane({ } catch { setHasClipboardText(false); } - }, [isMobile]); + }, [showVirtualKeyBar]); useEffect(() => { void refreshClipboardAvailability(); @@ -436,7 +464,7 @@ export function TerminalPane({ const handleKeyboardChange = useCallback( (nextShift: number) => { - setKeyboardInset(isMobile ? nextShift : 0); + setKeyboardInset(isNative || isMobile ? nextShift : 0); setIsKeyboardVisible(nextShift > 0); pulseKeyboardRefits(); }, @@ -955,6 +983,11 @@ export function TerminalPane({ [keyboardPaddingStyle], ); + const handleKeyBarLayout = useCallback((event: LayoutChangeEvent) => { + const width = event.nativeEvent.layout.width; + if (width > 0) setKeyBarWidth(width); + }, []); + const handleSwipeRight = useCallback(() => { if (!swipeGesturesEnabled) return; emulatorRef.current?.blur(); @@ -1099,10 +1132,14 @@ export function TerminalPane({ ) : null} - {isMobile ? ( + {showVirtualKeyBar ? ( - - {TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => ( + + {virtualKeyboardRows.map((row) => ( ({ }, keyboardRows: { gap: theme.spacing[1], + width: "100%", + maxWidth: TERMINAL_KEY_BAR_MAX_WIDTH, + alignSelf: "center", }, keyboardRow: { flexDirection: "row", diff --git a/packages/app/src/components/terminal-profile-icon.tsx b/packages/app/src/components/terminal-profile-icon.tsx index db3a216e7b..66cc00f69b 100644 --- a/packages/app/src/components/terminal-profile-icon.tsx +++ b/packages/app/src/components/terminal-profile-icon.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import { SquareTerminal } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import type { Theme } from "@/styles/theme"; const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -17,7 +17,7 @@ function ProviderProfileIcon({ size: number; color?: string; }) { - const Icon = getProviderIcon(iconKey); + const Icon = useProviderIcon(iconKey); return ; } diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 022a9bc7c2..33009ee376 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { @@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) setSheetData(null); }, [handleSheetDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const contextValue = useMemo( () => ({ openToolCall, closeToolCall }), [openToolCall, closeToolCall], @@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleToolCallSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > {sheetData && } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 74748e423a..736ca3bfa5 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,11 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - BottomSheetScrollView, - BottomSheetBackdrop, - BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; @@ -948,18 +944,6 @@ interface MobileBodyProps { } function MobileComboboxBody(props: MobileBodyProps): ReactElement { - const renderBackdrop = useCallback( - (backdropProps: React.ComponentProps) => ( - - ), - [], - ); - const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], @@ -991,7 +975,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { enableDynamicSizing={false} onChange={props.handleSheetChange} onDismiss={props.handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={ComboboxSheetBackground} handleIndicatorStyle={props.handleIndicatorStyle} diff --git a/packages/app/src/components/ui/external-link.tsx b/packages/app/src/components/ui/external-link.tsx index 2902678f60..a29018b042 100644 --- a/packages/app/src/components/ui/external-link.tsx +++ b/packages/app/src/components/ui/external-link.tsx @@ -19,7 +19,7 @@ export interface ExternalLinkProps { /** * Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the * URL via the platform's external opener. Wrap in a Tooltip when there's a - * one-line hint worth surfacing on hover/tap. + * one-line hint worth surfacing on hover. A native tap opens the URL. */ export function ExternalLink({ href, @@ -53,7 +53,7 @@ export function ExternalLink({ } return ( - + {trigger} {tooltip} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index f7869823b8..3f9c9113d0 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -1,9 +1,10 @@ import { BottomSheetModal as GorhomBottomSheetModal, type BottomSheetModalProps, + type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import React from "react"; -import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import type { ElementRef, ReactNode } from "react"; import { systemBackPress } from "./back-press"; import { @@ -11,6 +12,7 @@ import { createBottomSheetVisibilityTracker, } from "./visibility-tracker"; import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; +import { SheetBackdrop } from "./sheet-backdrop"; type GorhomBottomSheetModalMethods = ElementRef; @@ -36,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode; type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, - "enableDismissOnClose" | "stackBehavior" | "children" + "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge @@ -44,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit< */ children?: ReactNode; presentation?: "push" | "replace"; + /** Show a dismissible backdrop. Its interaction belongs to the modal stack. */ + backdropOpacity?: number; /** * Required, and `null` is a real answer: a sheet that needs nothing from its call site should * have to say so. The failure it prevents is invisible until someone adds a `useContext` deep @@ -58,13 +62,43 @@ export const IsolatedBottomSheetModal = forwardRef< IsolatedBottomSheetModalRef, IsolatedBottomSheetModalProps >(function IsolatedBottomSheetModal(props, ref) { - const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props; + // Gorhom puts this ref into its provider queue and reads `.current` for stack operations. + // A callback ref accepts the handle but leaves that queue unable to dismiss or restore it. + const modalRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + present: (...args) => modalRef.current?.present(...args), + dismiss: (...args) => modalRef.current?.dismiss(...args), + snapToIndex: (...args) => modalRef.current?.snapToIndex(...args), + snapToPosition: (...args) => modalRef.current?.snapToPosition(...args), + expand: (...args) => modalRef.current?.expand(...args), + collapse: (...args) => modalRef.current?.collapse(...args), + close: (...args) => modalRef.current?.close(...args), + forceClose: (...args) => modalRef.current?.forceClose(...args), + }), + [], + ); + const { + children, + presentation = "push", + contextBridge, + backdropOpacity, + ...bottomSheetProps + } = props; + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [backdropOpacity], + ); const modal = ( {contextBridge ? contextBridge(children) : children} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx new file mode 100644 index 0000000000..1232566462 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -0,0 +1,5 @@ +import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + return ; +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx new file mode 100644 index 0000000000..ba1e397a9e --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -0,0 +1,36 @@ +import { + BottomSheetBackdrop, + useBottomSheetModal, + type BottomSheetBackdropProps, +} from "@gorhom/bottom-sheet"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, StyleSheet, View } from "react-native"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + const { dismiss } = useBottomSheetModal(); + const { t } = useTranslation(); + const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + + return ( + + + + + ); +} diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 18cdb25dea..0b5a6a77ac 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -5,14 +5,13 @@ import { useEffect, useMemo, useRef, - type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -105,6 +104,8 @@ export interface MenuSurfaceProps { pages?: readonly MenuPageDefinition[]; /** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */ sheetTitle?: string; + /** Controls on the right of the sheet's root title. */ + sheetTrailing?: ReactNode; side?: Placement; align?: Alignment; offset?: number; @@ -339,9 +340,11 @@ function MenuSheetSurface({ children, pages = [], sheetTitle, + sheetTrailing, testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { + const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); const safeAreaInsets = useSafeAreaInsets(); @@ -367,18 +370,6 @@ function MenuSheetSurface({ onClose: handleClose, }); - const renderBackdrop = useCallback( - (backdropProps: ComponentProps) => ( - - ), - [], - ); - const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; @@ -402,9 +393,10 @@ function MenuSheetSurface({ // Content-sized rather than fixed snap points: a pushed page is rarely the same height // as the page it replaced, and a fixed sheet would either clip it or leave dead space. enableDynamicSizing + maxDynamicContentSize={windowHeight * 0.8} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose // `interactive` rather than `extend`, which is what every other sheet in the app uses. // `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that @@ -428,7 +420,9 @@ function MenuSheetSurface({ ) : ( <> - {sheetTitle ? : null} + {sheetTitle ? ( + + ) : null} {children} )} @@ -440,9 +434,11 @@ function MenuSheetSurface({ function MenuSheetHeader({ title, onBack, + trailing = null, }: { title: string; onBack: (() => void) | null; + trailing?: ReactNode; }): ReactElement { const { t } = useTranslation(); return ( @@ -462,6 +458,7 @@ function MenuSheetHeader({ {title} + {trailing ? {trailing} : null} ); } @@ -475,6 +472,11 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[1], paddingBottom: theme.spacing[3], }, + sheetTrailing: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + }, sheetBackButton: { width: 24, height: 24, diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index f2330e1b70..c1071ac882 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -8,17 +8,20 @@ interface StatusBadgeProps { label: string; variant?: StatusBadgeVariant; leading?: ReactNode; + /** `xs` fits beside a line of `sm` text: the same label on tighter padding. */ + size?: "sm" | "xs"; } -export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) { +export function StatusBadge({ label, variant = "muted", leading, size = "sm" }: StatusBadgeProps) { const pillStyle = useMemo( () => [ styles.pill, + size === "xs" && styles.pillXs, variant === "success" && styles.pillSuccess, variant === "warning" && styles.pillWarning, variant === "error" && styles.pillError, ], - [variant], + [size, variant], ); const textStyle = useMemo( () => [ @@ -50,6 +53,10 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], paddingVertical: 3, }, + pillXs: { + paddingHorizontal: theme.spacing[1.5], + paddingVertical: 1, + }, pillSuccess: { backgroundColor: theme.colors.statusSuccessTint, borderColor: "transparent", diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index f69c4feab9..22796e7ffe 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -28,7 +28,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; type Side = "top" | "bottom" | "left" | "right"; @@ -248,7 +248,8 @@ export function Tooltip({ }); const isCompact = useIsCompactFormFactor(); - const enabled = isCompact ? enabledOnMobile : enabledOnDesktop; + const opensOnPress = isNative || isCompact; + const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; const value = useMemo( () => ({ @@ -256,10 +257,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, - openOnPress: isCompact, + openOnPress: opensOnPress, delayDuration, }), - [isOpen, setIsOpen, enabled, isCompact, delayDuration], + [isOpen, setIsOpen, enabled, opensOnPress, delayDuration], ); return {children}; @@ -522,6 +523,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} style={contentStyle} @@ -542,12 +544,13 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={handleDismiss} > - + o.id === feature.value); + const iconOnly = feature.desktopTrigger === "icon"; + const tooltip = iconOnly + ? `${feature.label}: ${selectedOption?.label ?? feature.label}` + : getFeatureTooltip(feature); return ( <> @@ -1367,15 +1373,16 @@ function DesktopFeatureItem({ surface="toolbar" label={feature.label} value={selectedOption?.label ?? feature.label} + showToolbarLabel={!iconOnly} open={openSelector === featureSelector} disabled={disabled} onPress={handleSelectPress} - accessibilityLabel={getFeatureTooltip(feature)} + accessibilityLabel={tooltip} testID={`agent-feature-${feature.id}`} /> - {getFeatureTooltip(feature)} + {tooltip} 0 ? getProviderIcon(selectedProvider, serverId) : null; - const ModelIcon = ProviderIcon ?? Bot; + const ModelIcon = useProviderIcon(selectedProvider, serverId); + const hasSelectedProvider = selectedProvider.trim().length > 0; const rootHeader = useMemo( () => ({ ...rootBrowser.header, @@ -277,9 +275,9 @@ export function CompactModelSheet({ testID="combined-model-selector" chevron={null} > - {ProviderIcon ? ( + {hasSelectedProvider ? ( - + ) : null} diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 5b566fdeb4..1c91d8ce79 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -67,6 +67,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: "claude-fable-5[1m]", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -88,6 +89,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: "b", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -112,6 +114,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "high", }); @@ -119,6 +122,54 @@ describe("resolveAgentModelSelection", () => { expect(selection.displayThinking).toBe("High"); }); + it("shows the thinking level the agent runs on when none was chosen", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "openrouter/google/gemini-3.6-flash", + provider: "pi", + label: "Gemini 3.6 Flash", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], + runtimeModelId: "openrouter/google/gemini-3.6-flash", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + }); + + expect(selection.selectedThinkingId).toBe("high"); + expect(selection.displayThinking).toBe("High"); + }); + + it("falls back to the chosen thinking level when the runtime level is not a model option", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "a", + provider: "codex", + label: "Model A", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", + }, + ], + runtimeModelId: "a", + configuredModelId: null, + runtimeThinkingOptionId: "xhigh", + explicitThinkingOptionId: "high", + }); + + expect(selection.selectedThinkingId).toBe("high"); + }); + it("formats raw thinking labels in the selected model display", () => { const selection = resolveAgentModelSelection({ models: [ @@ -134,6 +185,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "xhigh", }); @@ -155,6 +207,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -178,6 +231,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "claude-sonnet-4-6-20260101", configuredModelId: "default", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -186,4 +240,31 @@ describe("resolveAgentModelSelection", () => { expect(selection.selectedThinkingId).toBe("low"); expect(selection.displayThinking).toBe("Low"); }); + + it("shows the agent's own model when the catalog does not list it", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "gpt-6-sol", + provider: "codex", + label: "GPT-6-Sol", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "medium" }, + { id: "max", label: "max" }, + ], + defaultThinkingOptionId: "medium", + }, + ], + runtimeModelId: "gpt-6.1-sol", + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "max", + }); + + expect(selection.selectedModel).toBeNull(); + expect(selection.activeModelId).toBe("gpt-6.1-sol"); + expect(selection.displayModel).toBe("gpt-6.1-sol"); + expect(selection.displayThinking).toBe("Max"); + }); }); diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index b28b693365..631b55753e 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -79,16 +79,23 @@ function pickSelectedModel( preferredModelId: string | null, fallbackModel: AgentModelDefinition | null, ): AgentModelDefinition | null { - if (!models || !preferredModelId) { + if (!preferredModelId) { return fallbackModel; } - return findModelById(models, preferredModelId) ?? fallbackModel; + return findModelById(models, preferredModelId); } function resolveThinkingId( + runtimeThinkingOptionId: string | null | undefined, explicitThinkingOptionId: string | null | undefined, selectedModel: AgentModelDefinition | null, ): string | null { + const runtimeThinkingOption = selectedModel?.thinkingOptions?.find( + (option) => option.id === runtimeThinkingOptionId, + ); + if (runtimeThinkingOption) { + return runtimeThinkingOption.id; + } if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") { return explicitThinkingOptionId; } @@ -139,9 +146,16 @@ export function resolveAgentModelSelection(input: { models: AgentModelDefinition[] | null; runtimeModelId: string | null | undefined; configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; explicitThinkingOptionId: string | null | undefined; }) { - const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; + const { + models, + runtimeModelId, + configuredModelId, + runtimeThinkingOptionId, + explicitThinkingOptionId, + } = input; const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); const normalizedConfiguredModelId = normalizeModelId(configuredModelId); @@ -162,9 +176,13 @@ export function resolveAgentModelSelection(input: { ); const thinkingOptions = selectedModel?.thinkingOptions ?? null; - const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel); + const resolvedThinkingId = resolveThinkingId( + runtimeThinkingOptionId, + explicitThinkingOptionId, + selectedModel, + ); const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId); - const selectedThinkingId = effectiveThinking?.id ?? null; + const selectedThinkingId = effectiveThinking?.id ?? resolvedThinkingId; const displayThinking = resolveThinkingDisplay( effectiveThinking, selectedThinkingId, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 28d23ae214..57b9e3fb44 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -48,7 +48,6 @@ import { type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; -import { UsageComposerPill } from "@/usage"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; @@ -280,6 +279,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) { } function renderContextWindowMeter( + serverId: string, + agentId: string, contextWindowMaxTokens: number | null, contextWindowUsedTokens: number | null, totalCostUsd: number | null, @@ -293,6 +294,8 @@ function renderContextWindowMeter( } return ( renderContextWindowMeter( + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, @@ -2085,6 +2090,8 @@ function ComposerContentImpl({ contextWindowMeterGlyphSize, ), [ + serverId, + agentId, contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, @@ -2093,13 +2100,8 @@ function ComposerContentImpl({ ], ); const beforeVoiceContent = useMemo( - () => ( - <> - {resolveContextWindowPlacement(contextWindowMeter, hasAgent)} - {hasAgent ? : null} - - ), - [agentId, contextWindowMeter, hasAgent, serverId], + () => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}, + [contextWindowMeter, hasAgent], ); const hasGithubAttachment = 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/data/provider-icons.ts b/packages/app/src/data/provider-icons.ts new file mode 100644 index 0000000000..9556be7ab2 --- /dev/null +++ b/packages/app/src/data/provider-icons.ts @@ -0,0 +1,17 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { createStore } from "zustand/vanilla"; + +export const providerSnapshotIcons = createStore>>( + () => new Map(), +); + +export function replaceProviderSnapshotIcons( + serverId: string, + entries: readonly Pick[], +): void { + const icons = new Map(); + for (const entry of entries) { + if (entry.iconSvg) icons.set(entry.provider, entry.iconSvg); + } + providerSnapshotIcons.setState((previous) => new Map(previous).set(serverId, icons), true); +} diff --git a/packages/app/src/data/providers-snapshot.test.ts b/packages/app/src/data/providers-snapshot.test.ts index 56cf80653a..c69eff9225 100644 --- a/packages/app/src/data/providers-snapshot.test.ts +++ b/packages/app/src/data/providers-snapshot.test.ts @@ -5,10 +5,7 @@ import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/mes import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import { applyProvidersSnapshotUpdate } from "./push-router"; import { createProviderSnapshotCache } from "./provider-snapshot-cache"; -import { - resolveProviderIconName, - replaceProviderSnapshotIcons, -} from "@/components/provider-icon-name"; +import { providerSnapshotIcons, replaceProviderSnapshotIcons } from "@/data/provider-icons"; import { describe, expect, it } from "vitest"; import { fetchProvidersSnapshot, @@ -143,10 +140,9 @@ describe("provider fetch with runtime abort signals", () => { expect(result.entries).toEqual(entries); expect(queryClient.getQueryData(queryKey)).toEqual(result); expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(entries); - expect(resolveProviderIconName("test-provider", serverId)).toEqual({ - kind: "svg", - svg: entries[0].iconSvg, - }); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + entries[0].iconSvg, + ); } finally { queryClient.clear(); replaceProviderSnapshotIcons(serverId, []); @@ -203,10 +199,9 @@ describe.each([ ).rejects.toBeInstanceOf(CancelledError); expect(controller.signal.aborted).toBe(true); expect(queryClient.getQueryData(queryKey)).toEqual(previous); - expect(resolveProviderIconName("test-provider", serverId)).toEqual({ - kind: "svg", - svg: "previous-icon", - }); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + "previous-icon", + ); expect((await cache.readHash(serverId, "test-hash"))?.entries ?? null).toEqual( hasBody ? entries : null, ); @@ -287,10 +282,9 @@ it("keeps a replacement query when the cancelled native fetch finishes late", as expect(queryClient.getQueryData(queryKey)).toEqual(replacement); expect(replacement.entries).toEqual(replacementEntries); expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(replacementEntries); - expect(resolveProviderIconName("test-provider", serverId)).toEqual({ - kind: "svg", - svg: "replacement-icon", - }); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + "replacement-icon", + ); } finally { finishResponse(); queryClient.clear(); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index fadaf5ee77..68b389fe15 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -7,7 +7,7 @@ import { type ProviderSnapshotCache, } from "./provider-snapshot-cache"; import { queryClient as singletonQueryClient } from "./query-client"; -import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; +import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; 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/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 83f62b4812..2f3c90b340 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -9,7 +9,7 @@ import { } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; -import { resolveProviderIconName } from "@/components/provider-icon-name"; +import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { fetchProvidersSnapshot, @@ -141,10 +141,9 @@ describe("fetchProvidersSnapshot", () => { await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() }); - expect(resolveProviderIconName("snapshot-icon-provider", serverId)).toEqual({ - kind: "svg", + expect(providerSnapshotIcons.getState().get(serverId)?.get("snapshot-icon-provider")).toEqual( svg, - }); + ); }); it("sends no cwd for the home scope", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index df738d7247..d396f26d9a 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -238,6 +238,98 @@ describe("loadAppSettingsFromStorage", () => { expect(result.sidebarNavItems).toEqual([]); }); + it("loads stored sidebar footer items in order and defaults them to empty", async () => { + expect((await loadAppSettingsFromStorage(makeDeps())).sidebarFooterItems).toEqual([]); + + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + sidebarFooterItems: [ + // A footer button that was configurable once; the sidebar model skips it. + { key: "help", visible: false }, + { key: "plugin:sync:status", visible: true }, + { key: "usage", visible: false }, + ], + }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sidebarFooterItems).toEqual([ + { key: "help", visible: false }, + { key: "plugin:sync:status", visible: true }, + { key: "usage", visible: false }, + ]); + }); + + it("loads legacy usage pins in order and defaults a fresh device to source defaults", async () => { + expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({ + displayAs: "used", + pins: null, + serverId: null, + }); + + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { + displayAs: "remaining", + pinned: [ + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ], + serverId: "server", + }, + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "remaining", + pins: [ + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ], + serverId: "server", + }); + }); + + it("loads an explicitly empty usage selection without restoring defaults", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { displayAs: "remaining", pins: [], serverId: "server" }, + }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "remaining", + pins: [], + serverId: "server", + }); + }); + + it("keeps valid usage preferences when one field is malformed", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { + displayAs: "percent", + pinned: [{ sourceId: "claude", windowId: "weekly" }], + serverId: 42, + }, + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "used", + pins: [{ sourceId: "claude", windowId: "weekly" }], + serverId: null, + }); + }); + it("collapses legacy diff destinations into the former Explorer choice", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 7171505492..a954074e8b 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -4,6 +4,11 @@ import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; import type { AppLanguage } from "@/i18n/locales"; import type { SidebarNavPreference } from "@/sidebar-nav/model"; +import { + DEFAULT_USAGE_PREFERENCES, + UsagePreferencesSchema, + type UsagePreferences, +} from "@/usage/preferences"; import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay, @@ -91,6 +96,10 @@ export interface AppSettings { sidebarChecksDisplay: SidebarChecksDisplay; /** Top-level sidebar rows in display order; empty means the default order, all visible. */ sidebarNavItems: SidebarNavPreference[]; + /** Sidebar footer items in display order; empty means the default order, all visible. */ + sidebarFooterItems: SidebarNavPreference[]; + /** How usage reads and which windows the sidebar summary shows. */ + usage: UsagePreferences; autoExpandReasoning: boolean; toolCallDetailLevel: ToolCallDetailLevel; chatOutlineEnabled: boolean; @@ -145,6 +154,8 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS, sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, sidebarNavItems: [], + sidebarFooterItems: [], + usage: DEFAULT_USAGE_PREFERENCES, autoExpandReasoning: false, toolCallDetailLevel: "detailed", chatOutlineEnabled: true, @@ -238,6 +249,8 @@ const StoredAppSettingsSchema = z .optional() .catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY), sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), + sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), + usage: UsagePreferencesSchema, autoExpandReasoning: z.boolean().catch(false), toolCallDetailLevel: z .enum(["overview", "detailed"]) diff --git a/packages/app/src/hosts/host-confirmation-sheet.tsx b/packages/app/src/hosts/host-confirmation-sheet.tsx new file mode 100644 index 0000000000..c83be51a30 --- /dev/null +++ b/packages/app/src/hosts/host-confirmation-sheet.tsx @@ -0,0 +1,109 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { useHostConfirmation } from "@/runtime/host-runtime"; + +/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */ +export function HostConfirmationSheet() { + const { t } = useTranslation(); + const { pending, answer } = useHostConfirmation(); + + const cancel = useCallback(() => answer(false), [answer]); + const connect = useCallback(() => answer(true), [answer]); + const header = useMemo(() => ({ title: t("pairing.hostConfirmation.title") }), [t]); + + if (!pending) return null; + + return ( + + + {pending.kind === "changedConnection" + ? t("pairing.hostConfirmation.descriptionChanged") + : t("pairing.hostConfirmation.description")} + + + + + + + + + + + + ); +} + +function DetailRow({ + label, + value, + showBorder = false, + testID, +}: { + label: string; + value: string; + showBorder?: boolean; + testID?: string; +}) { + return ( + + + {label} + + {value} + + + + ); +} + +// Same shape as the remove-host and remove-connection confirm sheets in +// screens/settings/host-page.tsx. +const styles = StyleSheet.create((theme) => ({ + confirmText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + confirmActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[4], + }, +})); + +const FLEX_1_STYLE = { flex: 1 }; diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/pair-scan-model.test.ts new file mode 100644 index 0000000000..0ca69b9d24 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime"; +import { openPairScan, type PairScanModel } from "./pair-scan-model"; + +const LINK = "https://app.paseo.sh/#offer=abc"; +const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) }; + +/** The host runtime's side of a scan: each import resolves with the next queued outcome. */ +function createScanner(outcomes: Array) { + const imports: string[] = []; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: async (link) => { + imports.push(link); + const outcome = outcomes.shift(); + if (!outcome) throw new Error("No outcome queued for this import"); + if (outcome instanceof Error) throw outcome; + return outcome; + }, + onConnected: (serverId) => connected.push(serverId), + }); + return { scan, imports, connected }; +} + +async function settled(scan: PairScanModel): Promise { + await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing")); +} + +describe("openPairScan", () => { + it("stops scanning after Cancel until the user scans again", async () => { + const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]); + + scan.scan(LINK); + await settled(scan); + + // The code is still in front of the camera. + scan.scan(LINK); + scan.scan(LINK); + await Promise.resolve(); + expect(imports).toEqual([LINK]); + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + + scan.scanAgain(); + scan.scan(LINK); + await settled(scan); + expect(imports).toEqual([LINK, LINK]); + }); + + it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => { + const { scan, imports, connected } = createScanner([ + { status: "connected", serverId: "srv_scan" }, + ]); + + scan.scan("https://example.com/not-a-pairing-link"); + expect(scan.getState()).toEqual({ status: "scanning" }); + + scan.scan(LINK); + scan.scan(LINK); + await settled(scan); + + expect(imports).toEqual([LINK]); + expect(connected).toEqual(["srv_scan"]); + scan.scan(LINK); + expect(imports).toEqual([LINK]); + }); + + it("ends the attempt when the password prompt closes", async () => { + const { scan, imports } = createScanner([ + { status: "password_required", link: LINK, pairing: PAIRING }, + ]); + + scan.scan(LINK); + await settled(scan); + expect(scan.getState()).toEqual({ + status: "passwordRequired", + passwordRequired: { link: LINK, pairing: PAIRING }, + }); + scan.scan(LINK); + + scan.closePassword(); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + expect(imports).toEqual([LINK]); + }); + + it("stops with the error when an attempt fails", async () => { + const { scan, imports } = createScanner([new Error("Server disconnected")]); + + scan.scan(LINK); + await settled(scan); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" }); + expect(imports).toEqual([LINK]); + }); + + it("does not navigate for an attempt that finishes after the screen closed", async () => { + let finish: (outcome: ConnectionLinkImport) => void = () => {}; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: () => + new Promise((resolve) => { + finish = resolve; + }), + onConnected: (serverId) => connected.push(serverId), + }); + + scan.scan(LINK); + scan.close(); + finish({ status: "connected", serverId: "srv_scan" }); + await Promise.resolve(); + + expect(connected).toEqual([]); + }); +}); diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/pair-scan-model.ts new file mode 100644 index 0000000000..4dca380543 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.ts @@ -0,0 +1,103 @@ +import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime"; + +/** + * One visit to the QR scan screen. The camera reports every frame that shows + * a code, so an attempt that ends (Cancel on the host confirmation, a closed + * password prompt, or an error) stops scanning until the user taps scan again. + * Without that, the code still in front of the camera starts a new attempt on + * the next frame. + */ +export type PairScanState = + | { status: "scanning" } + | { status: "pairing" } + | { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing } + | { status: "stopped"; error: string | null } + | { status: "connected" }; + +export interface PairScanModel { + getState: () => PairScanState; + subscribe: (listener: () => void) => () => void; + /** Data from a QR code the camera saw. Only a scanning session starts an attempt. */ + scan: (data: string) => void; + /** The user asked to scan again after an attempt ended. */ + scanAgain: () => void; + /** The user closed the password prompt, which ends the attempt. */ + closePassword: () => void; + close: () => void; +} + +export interface PairScanDependencies { + importConnectionLink: (link: string) => Promise; + onConnected: (serverId: string) => void; +} + +export function openPairScan(deps: PairScanDependencies): PairScanModel { + let state: PairScanState = { status: "scanning" }; + let closed = false; + const listeners = new Set<() => void>(); + + function publish(next: PairScanState): void { + if (closed) return; + state = next; + for (const listener of listeners) { + listener(); + } + } + + async function pair(link: string): Promise { + publish({ status: "pairing" }); + try { + const outcome = await deps.importConnectionLink(link); + if (closed) return; + if (outcome.status === "connected") { + publish({ status: "connected" }); + deps.onConnected(outcome.serverId); + return; + } + if (outcome.status === "password_required") { + const { link: passwordLink, pairing } = outcome; + publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } }); + return; + } + publish({ status: "stopped", error: null }); + } catch (error) { + publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) }); + } + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + scan(data) { + if (state.status !== "scanning") return; + const link = pairingLinkFromScan(data); + if (!link) return; + void pair(link); + }, + scanAgain() { + if (state.status !== "stopped") return; + publish({ status: "scanning" }); + }, + closePassword() { + if (state.status !== "passwordRequired") return; + publish({ status: "stopped", error: null }); + }, + close() { + closed = true; + listeners.clear(); + }, + }; +} + +function pairingLinkFromScan(data: string): string | null { + const raw = data.trim(); + if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) { + return raw; + } + return null; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a418df723f..d29de2b33d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -26,6 +26,7 @@ export const ar: TranslationResources = { total: "{{total}} تطابقات", }, common: { + bottomSheetBackdrop: "خلفية اللوحة السفلية", back: "خلف", loading: "تحميل...", actions: { @@ -439,7 +440,9 @@ export const ar: TranslationResources = { recovery: { archivedTitle: "مساحة العمل مؤرشفة", restoreDescription: - "تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.", + "استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.", + restoreWithoutBranchDescription: + "استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.", unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.", restoreAction: "استعادة", unarchiveAction: "إلغاء الأرشفة", @@ -1145,6 +1148,9 @@ export const ar: TranslationResources = { settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", }, + footer: { + usage: "الاستخدام", + }, help: { trigger: "المساعدة والدعم", sectionHelp: "المساعدة", @@ -1619,6 +1625,17 @@ export const ar: TranslationResources = { title: "كلمة المرور لـ {{host}}", label: "كلمة مرور المضيف", }, + hostConfirmation: { + title: "الاتصال بهذا المضيف؟", + description: + "سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.", + descriptionChanged: + "يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.", + hostLabel: "المضيف", + fingerprintLabel: "بصمة المفتاح", + relayLabel: "المُرحّل", + connect: "اتصال", + }, connectionMethods: { title: "إضافة اتصال", direct: { @@ -2166,8 +2183,15 @@ export const ar: TranslationResources = { description: "عرض مخطط للتنقل بين المطالبات", }, sidebar: { - title: "الشريط الجانبي", - description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", + header: { + title: "الرأس", + description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", + }, + footer: { + title: "التذييل", + description: + "اختر الصفوف التي تظهر أسفل الشريط الجانبي وترتيبها. يظهر «إضافة مشروع» وصف الأيقونات دائمًا", + }, moveUp: "نقل لأعلى", moveDown: "نقل لأسفل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 09774ae914..1e2d4c3f10 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -22,6 +22,7 @@ export const en = { total: "{{total}} matches", }, common: { + bottomSheetBackdrop: "Bottom sheet backdrop", back: "Back", loading: "Loading...", actions: { @@ -436,7 +437,9 @@ export const en = { recovery: { archivedTitle: "Workspace archived", restoreDescription: - "{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.", + "Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.", + restoreWithoutBranchDescription: + "Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.", unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.", restoreAction: "Restore", unarchiveAction: "Unarchive", @@ -1153,6 +1156,9 @@ export const en = { settings: "Settings", closeSidebar: "Close sidebar", }, + footer: { + usage: "Usage", + }, help: { trigger: "Help and support", sectionHelp: "Help", @@ -1644,6 +1650,17 @@ export const en = { title: "Password for {{host}}", label: "Host password", }, + hostConfirmation: { + title: "Connect to this host?", + description: + "This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.", + descriptionChanged: + "This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.", + hostLabel: "Host", + fingerprintLabel: "Key fingerprint", + relayLabel: "Relay", + connect: "Connect", + }, connectionMethods: { title: "Add connection", direct: { @@ -2290,8 +2307,15 @@ export const en = { description: "Show an outline for jumping between prompts", }, sidebar: { - title: "Sidebar", - description: "Choose which items appear at the top of the sidebar and in what order", + header: { + title: "Header", + description: "Choose which items appear at the top of the sidebar and in what order", + }, + footer: { + title: "Footer", + description: + "Choose which rows appear at the bottom of the sidebar and in what order. Add project and the icon row always show", + }, moveUp: "Move up", moveDown: "Move down", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3227988de9..cc37432b5b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -27,6 +27,7 @@ export const es: TranslationResources = { total: "{{total}} coincidencias", }, common: { + bottomSheetBackdrop: "Fondo del panel inferior", back: "Atrás", loading: "Cargando...", actions: { @@ -444,7 +445,9 @@ export const es: TranslationResources = { recovery: { archivedTitle: "Espacio de trabajo archivado", restoreDescription: - "{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.", + "Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.", + restoreWithoutBranchDescription: + "Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.", unarchiveDescription: "{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.", restoreAction: "Restaurar", @@ -1181,6 +1184,9 @@ export const es: TranslationResources = { settings: "Ajustes", closeSidebar: "Cerrar barra lateral", }, + footer: { + usage: "Uso", + }, help: { trigger: "Ayuda y soporte", sectionHelp: "Ayuda", @@ -1665,6 +1671,17 @@ export const es: TranslationResources = { title: "Contraseña de {{host}}", label: "Contraseña del host", }, + hostConfirmation: { + title: "¿Conectar con este host?", + description: + "Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.", + descriptionChanged: + "Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.", + hostLabel: "Host", + fingerprintLabel: "Huella de la clave", + relayLabel: "Relay", + connect: "Conectar", + }, connectionMethods: { title: "Agregar conexión", direct: { @@ -2218,9 +2235,16 @@ export const es: TranslationResources = { description: "Muestra un esquema para saltar entre instrucciones", }, sidebar: { - title: "Barra lateral", - description: - "Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden", + header: { + title: "Encabezado", + description: + "Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden", + }, + footer: { + title: "Pie", + description: + "Elige qué filas aparecen en la parte inferior de la barra lateral y en qué orden. Añadir proyecto y la fila de iconos siempre se muestran", + }, moveUp: "Mover hacia arriba", moveDown: "Mover hacia abajo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ca3343000f..aaf2608f9e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -27,6 +27,7 @@ export const fr: TranslationResources = { total: "{{total}} résultats", }, common: { + bottomSheetBackdrop: "Arrière-plan du panneau inférieur", back: "Dos", loading: "Chargement...", actions: { @@ -445,7 +446,9 @@ export const fr: TranslationResources = { recovery: { archivedTitle: "Espace de travail archivé", restoreDescription: - "{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.", + "Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.", + restoreWithoutBranchDescription: + "Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.", unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", restoreAction: "Restaurer", unarchiveAction: "Désarchiver", @@ -1181,6 +1184,9 @@ export const fr: TranslationResources = { settings: "Paramètres", closeSidebar: "Fermer la barre latérale", }, + footer: { + usage: "Utilisation", + }, help: { trigger: "Aide et assistance", sectionHelp: "Aide", @@ -1669,6 +1675,17 @@ export const fr: TranslationResources = { title: "Mot de passe pour {{host}}", label: "Mot de passe de l’hôte", }, + hostConfirmation: { + title: "Se connecter à cet hôte ?", + description: + "Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + descriptionChanged: + "Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + hostLabel: "Hôte", + fingerprintLabel: "Empreinte de la clé", + relayLabel: "Relais", + connect: "Se connecter", + }, connectionMethods: { title: "Ajouter une connexion", direct: { @@ -2222,8 +2239,16 @@ export const fr: TranslationResources = { description: "Afficher un plan pour passer d’une requête à l’autre", }, sidebar: { - title: "Barre latérale", - description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", + header: { + title: "En-tête", + description: + "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", + }, + footer: { + title: "Pied", + description: + "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. Ajouter un projet et la rangée d’icônes restent toujours visibles", + }, moveUp: "Déplacer vers le haut", moveDown: "Déplacer vers le bas", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 31c4c8fb61..3491b507a2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -27,6 +27,7 @@ export const ja: TranslationResources = { total: "{{total}} 件の一致", }, common: { + bottomSheetBackdrop: "ボトムシートの背景", back: "戻る", loading: "読み込み中...", actions: { @@ -444,7 +445,9 @@ export const ja: TranslationResources = { recovery: { archivedTitle: "ワークスペースはアーカイブ済みです", restoreDescription: - "{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。", + "{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。", + restoreWithoutBranchDescription: + "{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。", unarchiveDescription: "{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。", restoreAction: "復元", @@ -1159,6 +1162,9 @@ export const ja: TranslationResources = { settings: "設定", closeSidebar: "サイドバーを閉じる", }, + footer: { + usage: "使用状況", + }, help: { trigger: "ヘルプとサポート", sectionHelp: "ヘルプ", @@ -1636,6 +1642,17 @@ export const ja: TranslationResources = { title: "{{host}} のパスワード", label: "ホストのパスワード", }, + hostConfirmation: { + title: "このホストに接続しますか?", + description: + "このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。", + descriptionChanged: + "このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。", + hostLabel: "ホスト", + fingerprintLabel: "鍵のフィンガープリント", + relayLabel: "リレー", + connect: "接続", + }, connectionMethods: { title: "接続を追加", direct: { @@ -2185,8 +2202,15 @@ export const ja: TranslationResources = { description: "プロンプト間を移動するためのアウトラインを表示します", }, sidebar: { - title: "サイドバー", - description: "サイドバー上部に表示する項目とその順序を選択します", + header: { + title: "ヘッダー", + description: "サイドバー上部に表示する項目とその順序を選択します", + }, + footer: { + title: "フッター", + description: + "サイドバー下部に表示する行とその順序を選択します。プロジェクトを追加とアイコンの行は常に表示されます", + }, moveUp: "上に移動", moveDown: "下に移動", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index cf5ca5ea41..7afe54b158 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -26,6 +26,7 @@ export const ko: TranslationResources = { total: "일치 항목 {{total}}개", }, common: { + bottomSheetBackdrop: "하단 시트 배경", back: "뒤로", loading: "불러오는 중...", actions: { @@ -440,7 +441,9 @@ export const ko: TranslationResources = { recovery: { archivedTitle: "워크스페이스가 보관되었습니다", restoreDescription: - "{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.", + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.", + restoreWithoutBranchDescription: + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.", unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.", restoreAction: "복원", unarchiveAction: "보관 취소", @@ -1152,6 +1155,9 @@ export const ko: TranslationResources = { settings: "설정", closeSidebar: "사이드바 닫기", }, + footer: { + usage: "사용량", + }, help: { trigger: "도움말 및 지원", sectionHelp: "도움말", @@ -1629,6 +1635,17 @@ export const ko: TranslationResources = { title: "{{host}}의 비밀번호", label: "호스트 비밀번호", }, + hostConfirmation: { + title: "이 호스트에 연결할까요?", + description: + "이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.", + descriptionChanged: + "이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.", + hostLabel: "호스트", + fingerprintLabel: "키 지문", + relayLabel: "릴레이", + connect: "연결", + }, connectionMethods: { title: "연결 추가", direct: { @@ -2177,8 +2194,15 @@ export const ko: TranslationResources = { description: "프롬프트 사이를 이동하기 위한 개요 표시", }, sidebar: { - title: "사이드바", - description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", + header: { + title: "헤더", + description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", + }, + footer: { + title: "푸터", + description: + "사이드바 하단에 표시할 행과 순서를 선택하세요. 프로젝트 추가와 아이콘 행은 항상 표시됩니다", + }, moveUp: "위로 이동", moveDown: "아래로 이동", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5d4e10c14e..a08088b2e4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -27,6 +27,7 @@ export const ptBR: TranslationResources = { total: "{{total}} correspondências", }, common: { + bottomSheetBackdrop: "Fundo do painel inferior", back: "Voltar", loading: "Carregando...", actions: { @@ -443,7 +444,9 @@ export const ptBR: TranslationResources = { recovery: { archivedTitle: "Workspace arquivado", restoreDescription: - "{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.", + "Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.", + restoreWithoutBranchDescription: + "Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.", unarchiveDescription: "{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.", restoreAction: "Restaurar", @@ -1171,6 +1174,9 @@ export const ptBR: TranslationResources = { settings: "Configurações", closeSidebar: "Fechar barra lateral", }, + footer: { + usage: "Uso", + }, help: { trigger: "Ajuda e suporte", sectionHelp: "Ajuda", @@ -1650,6 +1656,17 @@ export const ptBR: TranslationResources = { title: "Senha de {{host}}", label: "Senha do host", }, + hostConfirmation: { + title: "Conectar a este host?", + description: + "Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.", + descriptionChanged: + "Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.", + hostLabel: "Host", + fingerprintLabel: "Impressão da chave", + relayLabel: "Relay", + connect: "Conectar", + }, connectionMethods: { title: "Adicionar conexão", direct: { @@ -2201,8 +2218,15 @@ export const ptBR: TranslationResources = { description: "Mostrar uma estrutura para navegar entre prompts", }, sidebar: { - title: "Barra lateral", - description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", + header: { + title: "Cabeçalho", + description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", + }, + footer: { + title: "Rodapé", + description: + "Escolha quais linhas aparecem na parte inferior da barra lateral e em que ordem. Adicionar projeto e a linha de ícones sempre aparecem", + }, moveUp: "Mover para cima", moveDown: "Mover para baixo", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 271eafaa28..4526784896 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -27,6 +27,7 @@ export const ru: TranslationResources = { total: "Совпадений: {{total}}", }, common: { + bottomSheetBackdrop: "Фон нижней панели", back: "Назад", loading: "Загрузка...", actions: { @@ -443,7 +444,9 @@ export const ru: TranslationResources = { recovery: { archivedTitle: "Рабочая область в архиве", restoreDescription: - "Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.", + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.", + restoreWithoutBranchDescription: + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.", unarchiveDescription: "Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.", restoreAction: "Восстановить", @@ -1163,6 +1166,9 @@ export const ru: TranslationResources = { settings: "Настройки", closeSidebar: "Закрыть боковую панель", }, + footer: { + usage: "Использование", + }, help: { trigger: "Помощь и поддержка", sectionHelp: "Помощь", @@ -1648,6 +1654,17 @@ export const ru: TranslationResources = { title: "Пароль для {{host}}", label: "Пароль хоста", }, + hostConfirmation: { + title: "Подключиться к этому хосту?", + description: + "Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.", + descriptionChanged: + "Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.", + hostLabel: "Хост", + fingerprintLabel: "Отпечаток ключа", + relayLabel: "Реле", + connect: "Подключить", + }, connectionMethods: { title: "Добавить подключение", direct: { @@ -2204,9 +2221,16 @@ export const ru: TranslationResources = { description: "Показывать оглавление для перехода между запросами", }, sidebar: { - title: "Боковая панель", - description: - "Выберите, какие элементы отображаются вверху боковой панели и в каком порядке", + header: { + title: "Верх", + description: + "Выберите, какие элементы отображаются вверху боковой панели и в каком порядке", + }, + footer: { + title: "Низ", + description: + "Выберите, какие строки отображаются внизу боковой панели и в каком порядке. «Добавить проект» и ряд значков видны всегда", + }, moveUp: "Переместить вверх", moveDown: "Переместить вниз", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index cdeb534e58..d13007dbea 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -26,6 +26,7 @@ export const zhCN: TranslationResources = { total: "{{total}} 个匹配项", }, common: { + bottomSheetBackdrop: "底部面板背景", back: "返回", loading: "加载中...", actions: { @@ -439,7 +440,9 @@ export const zhCN: TranslationResources = { recovery: { archivedTitle: "Workspace 已归档", restoreDescription: - "{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。", + "恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。", + restoreWithoutBranchDescription: + "恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。", unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。", restoreAction: "恢复", unarchiveAction: "取消归档", @@ -1137,6 +1140,9 @@ export const zhCN: TranslationResources = { settings: "设置", closeSidebar: "关闭侧边栏", }, + footer: { + usage: "使用情况", + }, help: { trigger: "帮助与支持", sectionHelp: "帮助", @@ -1602,6 +1608,16 @@ export const zhCN: TranslationResources = { title: "{{host}} 的密码", label: "主机密码", }, + hostConfirmation: { + title: "连接到此主机?", + description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。", + descriptionChanged: + "此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。", + hostLabel: "主机", + fingerprintLabel: "密钥指纹", + relayLabel: "中继", + connect: "连接", + }, connectionMethods: { title: "添加连接", direct: { @@ -2142,8 +2158,14 @@ export const zhCN: TranslationResources = { description: "显示用于在提示词之间跳转的大纲", }, sidebar: { - title: "侧边栏", - description: "选择侧边栏顶部显示的项目及其顺序", + header: { + title: "顶部", + description: "选择侧边栏顶部显示的项目及其顺序", + }, + footer: { + title: "底部", + description: "选择侧边栏底部显示的行及其顺序。添加项目和图标行始终显示", + }, moveUp: "上移", moveDown: "下移", }, 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/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 8fc18eb0e3..6c2d482403 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { useShallow } from "zustand/react/shallow"; import { AgentStreamView } from "@/agent-stream/view"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { resolveComposerTrackControlClearance, resolveComposerTrackTailClearance, @@ -97,7 +97,7 @@ function useProviderSubagentDescriptor( subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel, tooltip: label, titleState: descriptor ? "ready" : "loading", - icon: getProviderIcon(provider, context.serverId), + icon: useProviderIcon(provider, context.serverId), statusBucket: descriptor ? deriveSidebarStateBucket({ status: providerSubagentLifecycleStatus(descriptor.status), diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 9f2329df09..b61eae7c1d 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -3,16 +3,17 @@ import type { PluginAgentCommandContext, PluginCommandCapabilities, PluginPanelLocation, + PluginScreenParams, PluginWorkspaceCommandContext, } from "@getpaseo/plugin/client"; import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; +import { parsePluginOpenScreenInput } from "./surface-contribution"; import type { InstalledPlugin } from "./types"; export interface PluginNavigation { openSettings(pluginId: string, screenId: string): void; - openSurface(pluginId: string, surfaceId: string): void; + openSurface(pluginId: string, surfaceId: string, params?: PluginScreenParams): void; openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void; openAgentPanel( pluginId: string, @@ -24,41 +25,40 @@ export interface PluginNavigation { export function createPluginCapabilities( plugin: InstalledPlugin, - runtime: PluginSurfaceRuntime, navigation: PluginNavigation, ): PluginCommandCapabilities { + function openScreen(input: unknown) { + const { screenId, params } = parsePluginOpenScreenInput(plugin, input); + navigation.openSurface(plugin.id, screenId, params); + } return { - paseo: runtime.paseo, - rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input), + paseo: plugin.paseo, + rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input), openSettings(screenId) { if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) throw new Error(`Plugin settings screen is unavailable: ${screenId}`); navigation.openSettings(plugin.id, screenId); }, - openSurface(surfaceId) { - if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) { - throw new Error(`Plugin surface is unavailable: ${surfaceId}`); - } - navigation.openSurface(plugin.id, surfaceId); - }, + openScreen, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + openSurface: (screenId) => openScreen({ screenId }), }; } export function createPluginAgentActionContext(input: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; navigation: PluginNavigation; state: PluginClientStateSource; workspaceId: string; agentId: string; }): PluginAgentCommandContext | null { - const { plugin, runtime, navigation, state, workspaceId, agentId } = input; + const { plugin, navigation, state, workspaceId, agentId } = input; const workspace = state.getWorkspace(workspaceId); const agent = state.getAgent(agentId); if (!workspace || !agent || agent.workspaceId !== workspace.id) return null; return { context: "agent", - ...createPluginCapabilities(plugin, runtime, navigation), + ...createPluginCapabilities(plugin, navigation), workspace, agent, openPanel(panelId, options) { @@ -76,17 +76,16 @@ export function createPluginAgentActionContext(input: { export function createPluginWorkspaceActionContext(input: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; navigation: PluginNavigation; state: PluginClientStateSource; workspaceId: string; }): PluginWorkspaceCommandContext | null { - const { plugin, runtime, navigation, state, workspaceId } = input; + const { plugin, navigation, state, workspaceId } = input; const workspace = state.getWorkspace(workspaceId); if (!workspace) return null; return { context: "workspace", - ...createPluginCapabilities(plugin, runtime, navigation), + ...createPluginCapabilities(plugin, navigation), workspace, openPanel(panelId, options) { const panel = plugin.workspacePanels.find( diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 028114c862..4a77a81311 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -11,10 +11,13 @@ function installation(): InstalledPlugin { serverId: "host-a", clientBundle: "bundle", queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => undefined, settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index b737b3ebf0..7eae9a046d 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -1,4 +1,3 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import type { PluginButtonBehavior, PluginButtonIcon, @@ -7,14 +6,13 @@ import type { } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; -import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { MenuRoot, MenuTrigger, - MenuSurface, MenuItem, MenuSeparator, MenuSubTrigger, @@ -30,22 +28,27 @@ import { } from "@/components/ui/icon-button-chrome"; import { composerPillStyles } from "@/composer/pill-styles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { ToastApiProvider, useToast } from "@/contexts/toast-context"; +import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; import { createPluginClientStateSource } from "../client-state/source"; import { Icon } from "../icons"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { + PluginEnvironmentProvider, + PluginPopoverContent, + PluginPopoverSurface, + type PluginEnvironment, +} from "../popover"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; import { buttonMatches, type RegisteredPluginButton } from "./model"; import { pluginButtonStore } from "./store"; +import { resolvePluginPlatform } from "../platform"; interface ButtonView { entry: RegisteredPluginButton; props: PluginHostProps & RegisteredPluginButton["context"]; - client: NonNullable>; - state: ReturnType; + environment: PluginEnvironment; toast: ReturnType; } @@ -67,21 +70,9 @@ function headerButtonStyle(compact: boolean, state: IconButtonChromeState, disab ]; } -function resolvePlatform(): PluginHostProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - -// These providers live inside the surface content as well as around its trigger. Native sheets -// teleport their children, so providers around MenuRoot alone cannot reach the plugin body. function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) { return ( - - - {children} - - + {children} ); } @@ -184,9 +175,9 @@ function ButtonBody({ if (behavior.kind === "popover") { const Content = behavior.Content; return ( - + - + ); } if (behavior.kind !== "menu") return null; @@ -336,19 +327,15 @@ function ButtonControl({ view }: { view: ButtonView }) { {expanded ? ( - - + ) : null} ); @@ -398,14 +385,17 @@ function createButtonView({ if (!client) return null; return { entry, - client, toast, - state: createPluginClientStateSource(entry.installation.serverId), + environment: { + installation: entry.installation, + toast, + state: createPluginClientStateSource(entry.installation.serverId), + }, props: { ...entry.context, theme, host: { id: entry.installation.serverId, label: hostLabel }, - layout: { compact, platform: resolvePlatform() }, + layout: { compact, platform: resolvePluginPlatform() }, }, }; } @@ -511,17 +501,13 @@ function OverflowPages({ }, [entries, client, toast, theme, hostLabel, compact]); const { t } = useTranslation(); return ( - {menuContent.rows} - + ); } @@ -665,5 +651,4 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, overflow: "hidden", }, - content: { padding: theme.spacing[3], gap: theme.spacing[2] }, })); 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 6e7b13501b..f9074383bd 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,6 +1,6 @@ +import { createPlayAudio, type AudioEngine } from "@/audio"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { createPluginHosts } from "./hosts"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { createPluginAgentActionContext, @@ -11,23 +11,20 @@ import { createPluginClientStateSource } from "./client-state/source"; import type { PluginClientRuntime } from "./evaluate"; import { createPluginNavigation } from "./navigation"; import { pluginButtonStore } from "./buttons"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; import type { InstalledPlugin } from "./types"; export function createPluginClientRuntime( installation: InstalledPlugin, - daemonClient: DaemonClient, + audio: Pick, ): PluginClientRuntime { - const runtime = createPluginSurfaceRuntime(daemonClient, installation); - if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( installation, - runtime, createPluginNavigation({ serverId: installation.serverId, workspaceId: null }), ); return { ...capabilities, + playAudio: createPlayAudio(audio, installation.lifetime.signal), hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); @@ -36,26 +33,24 @@ export function createPluginClientRuntime( return pluginButtonStore.addHeaderButton(installation, contribution); }, openPanel(panelId, options) { - openClientPanel({ installation, runtime, state, panelId, options }); + openClientPanel({ installation, state, panelId, options }); }, }; } function openClientPanel(input: { installation: InstalledPlugin; - runtime: NonNullable>; state: ReturnType; panelId: string; options: PluginClientOpenPanelOptions; }): void { - const { installation, runtime, state, panelId, options } = input; + const { installation, state, panelId, options } = input; const workspaceId = options.workspaceId.trim(); const agentId = options.agentId?.trim(); const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId }); const action = agentId ? createPluginAgentActionContext({ plugin: installation, - runtime, navigation, state, workspaceId, @@ -63,7 +58,6 @@ function openClientPanel(input: { }) : createPluginWorkspaceActionContext({ plugin: installation, - runtime, navigation, state, workspaceId, diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index 57d205a7b9..9ab66ac195 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -1,10 +1,8 @@ import { useMemo } from "react"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions"; import { createPluginClientStateSource } from "../client-state/source"; import { createPluginNavigation } from "../navigation"; import { useInstalledPlugins } from "../registry"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; export interface PluginClientSlashCommand { pluginId: string; @@ -19,10 +17,9 @@ export function usePluginClientSlashCommands(input: { workspaceId: string | null | undefined; agentId: string; }): PluginClientSlashCommand[] { - const client = useHostRuntimeClient(input.serverId); const installed = useInstalledPlugins(); return useMemo(() => { - if (!client || !input.workspaceId) return []; + if (!input.workspaceId) return []; const workspaceId = input.workspaceId; const state = createPluginClientStateSource(input.serverId); const navigation = createPluginNavigation({ @@ -42,38 +39,30 @@ export function usePluginClientSlashCommands(input: { description: contribution.description, argumentHint: contribution.argumentHint, async run(args: string) { - const runtime = createPluginSurfaceRuntime(client, plugin); - if (!runtime) return; - try { - const context = - contribution.context === "agent" - ? createPluginAgentActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - agentId: input.agentId, - }) - : createPluginWorkspaceActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - }); - if (contribution.context === "agent" && context?.context === "agent") - await contribution.onSubmit({ ...context, args }); - else if (contribution.context === "workspace" && context?.context === "workspace") - await contribution.onSubmit({ ...context, args }); - } finally { - await runtime.paseo.dispose(); - } + const context = + contribution.context === "agent" + ? createPluginAgentActionContext({ + plugin, + navigation, + state, + workspaceId, + agentId: input.agentId, + }) + : createPluginWorkspaceActionContext({ + plugin, + navigation, + state, + workspaceId, + }); + if (contribution.context === "agent" && context?.context === "agent") + await contribution.onSubmit({ ...context, args }); + else if (contribution.context === "workspace" && context?.context === "workspace") + await contribution.onSubmit({ ...context, args }); }, }, ]; }), ); return commands; - }, [client, input.agentId, input.serverId, input.workspaceId, installed]); + }, [input.agentId, input.serverId, input.workspaceId, installed]); } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 89313f907c..a2544bd034 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -61,11 +61,23 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "plugin-command-test", + clientType: "cli", + }), + ), + invoke: async (method: string, input: unknown) => { + expect(method).toBe("review.inspect"); + return { value: inspect.input.parse(input).value + 1 }; + }, queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], - surfaces: [{ id: "main", Component: () => null }], - sidebarItems: [], + surfaces: [{ id: "main", title: "Main", Component: () => null }], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [ { id: "details", @@ -121,28 +133,11 @@ function stateSource() { }; } -function createRuntime(installed: InstalledPlugin) { - const client = new DaemonClient({ - url: "ws://127.0.0.1:1", - clientId: "plugin-command-test", - clientType: "cli", - }); - return { - paseo: createPaseoApi(client), - invoke: async (method: string, input: unknown) => { - expect(installed.id).toBe("review"); - expect(method).toBe("review.inspect"); - return { value: inspect.input.parse(input).value + 1 }; - }, - }; -} - describe("plugin Command Center contributions", () => { it("shows only contributions whose synchronous context exists", () => { const installed = plugin(() => undefined); const common = { plugins: [installed], - runtime: createRuntime, state: stateSource(), navigation: { openSettings() {}, @@ -188,10 +183,8 @@ describe("plugin Command Center contributions", () => { context.openSurface("main"); context.openPanel("details", { location: "explorer" }); }); - const runtime = createRuntime(installed); const actions = buildPluginCommandCenterContributions({ plugins: [installed], - runtime: () => runtime, state: stateSource(), workspaceId: workspace.id, agentId: agent.id, @@ -215,7 +208,8 @@ describe("plugin Command Center contributions", () => { await actions.find((action) => action.id === "review:agent")?.run(); expect(rpcValue).toBe(5); - expect(receivedPaseo).toBe(runtime.paseo); + // Commands use the plugin's one client. + expect(receivedPaseo).toBe(installed.paseo); expect(opened).toEqual(["review/surface/main", "review/agent/details/agent-1/explorer"]); }); @@ -223,7 +217,6 @@ describe("plugin Command Center contributions", () => { expect( buildPluginCommandCenterContributions({ plugins: [], - runtime: createRuntime, state: stateSource(), workspaceId: workspace.id, agentId: agent.id, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 9d40ee57e2..be4cb8068a 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -3,13 +3,11 @@ import type { CommandCenterContribution } from "@/command-center/contributions"; import { getCommandCenterIcon } from "@/command-center/icon"; import { resolvePluginIcon } from "../icons"; import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations"; -import type { PluginSurfaceRuntime } from "../surface-runtime"; import type { InstalledPlugin } from "../types"; import { createPluginCapabilities, type PluginNavigation } from "../actions"; export interface PluginCommandCenterSource { plugins: readonly InstalledPlugin[]; - runtime(plugin: InstalledPlugin): PluginSurfaceRuntime; state: PluginClientStateSource; workspaceId: string | null; agentId: string | null; @@ -26,8 +24,7 @@ export function buildPluginCommandCenterContributions( if (item.context === "workspace" && !source.workspaceId) continue; if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue; const run = async () => { - const runtime = source.runtime(plugin); - const common = createPluginCapabilities(plugin, runtime, source.navigation); + const common = createPluginCapabilities(plugin, source.navigation); try { if (item.context === "global") { await item.onSelect({ context: "global", ...common }); @@ -73,8 +70,6 @@ export function buildPluginCommandCenterContributions( }); } catch (error) { source.reportError(error); - } finally { - await runtime.paseo.dispose().catch(source.reportError); } }; contributions.push({ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index cd85f9bff6..faf3b9215f 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -11,7 +11,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; import { hostIdFromPathname } from "../routes"; import { useInstalledPlugins } from "../registry"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { buildPluginCommandCenterContributions } from "./contributions"; import { getFocusedAgentId } from "./context"; import { createPluginNavigation } from "../navigation"; @@ -53,11 +52,6 @@ export function PluginCommandCenterActions() { if (!client || !serverId || !stateSource) return []; return buildPluginCommandCenterContributions({ plugins, - runtime(plugin) { - const runtime = createPluginSurfaceRuntime(client, plugin); - if (!runtime) throw new Error("Plugin host is offline"); - return runtime; - }, state: stateSource, workspaceId: workspaceExists ? workspaceId : null, agentId: agentExists ? focusedAgentId : null, diff --git a/packages/app/src/plugins/contribution-host.ts b/packages/app/src/plugins/contribution-host.ts index cbead73be5..9614594802 100644 --- a/packages/app/src/plugins/contribution-host.ts +++ b/packages/app/src/plugins/contribution-host.ts @@ -7,3 +7,17 @@ export function getPreferredPluginContributionHost(contributionId: string): stri export function rememberPluginContributionHost(contributionId: string, serverId: string): void { preferredHosts.set(contributionId, serverId); } + +/** + * The host a plugin's screens and sidebar items share: the host last picked on one of its screens + * or last opened from one of its items. + */ +export function pluginScreensHostKey(pluginId: string): string { + return `${pluginId}/screens`; +} + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +/** The host an `addSidebarItem` row last opened, which its screen's host switcher also sets. */ +export function legacySidebarItemHostKey(pluginId: string, itemId: string): string { + return `${pluginId}/sidebar/${itemId}`; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 94f87d93ed..310ef5f7b9 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -5,6 +5,7 @@ const runtime = { paseo: {}, async rpc() {}, openSettings() {}, + openScreen() {}, openSurface() {}, openPanel() {}, addHeaderButton() { @@ -72,9 +73,10 @@ describe("evaluatePluginClientBundle", () => { function Component() { return null; } const schema = { safeParse(value) { return { success: true, data: value }; } }; globalThis.__pluginRemovals = [ - plugin.addSurface("main", Component), + plugin.addScreen({ id: "main", title: "Main", Component }), plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }), - plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }), + plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }), + plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }), plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }), plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }), plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }), @@ -104,7 +106,8 @@ describe("evaluatePluginClientBundle", () => { [ plugin.surfaces, plugin.settingsScreens, - plugin.sidebarItems, + plugin.sidebarItems.header, + plugin.sidebarItems.footer, plugin.workspacePanels, plugin.commandCenterItems, plugin.clientSlashCommands, @@ -123,7 +126,8 @@ describe("evaluatePluginClientBundle", () => { [ plugin.surfaces, plugin.settingsScreens, - plugin.sidebarItems, + plugin.sidebarItems.header, + plugin.sidebarItems.footer, plugin.workspacePanels, plugin.commandCenterItems, plugin.clientSlashCommands, @@ -180,21 +184,125 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("Timeline transformer bad-query has invalid item type: settled"); }); - it("collects a surface and its sidebar placement", () => { + it("collects screens and sidebar header and footer items", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + function Screen() { return null; } + const Item = require("react").memo(function Item() { return null; }); + plugin.addScreen({ id: "main", title: " Main ", Component: Screen }); + plugin.addScreen({ id: "bot", title: (params) => params.botId, Component: Screen }); + plugin.addSidebarHeaderItem({ id: "main", title: " Example ", Component: Item }); + plugin.addSidebarFooterItem({ id: "main", title: "Status", Component: Item }); + `), + ); + + expect(plugin.id).toBe("example"); + expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main", "bot"]); + expect(plugin.surfaces[0]?.title).toBe("Main"); + expect(plugin.surfaces[1]?.title).toBeTypeOf("function"); + expect(plugin.sidebarItems.header.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "Example" }, + ]); + expect(plugin.sidebarItems.footer.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "Status" }, + ]); + expect(plugin.legacySidebarItems).toEqual([]); + }); + + it.each([ + [`plugin.addScreen("main", Screen);`, "addScreen takes { id, title, Component }"], + [`plugin.addScreen({ id: "main", Component: Screen });`, "Screen main needs a title"], + [ + `plugin.addScreen({ id: "main", title: " ", Component: Screen });`, + "Screen main needs a title", + ], + [ + `plugin.addScreen({ id: "main", title: 42, Component: Screen });`, + "Screen main needs a title", + ], + [ + `plugin.addScreen({ id: "main", title: "Main", Component: "Main" });`, + "Screen main is not a component", + ], + ])("rejects a malformed screen: %s", (call, message) => { + expect(() => + evaluatePluginClientBundle("example", bundle(`function Screen() { return null; } ${call}`)), + ).toThrow(message); + }); + + it("rejects duplicate and malformed sidebar items within a section", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + function Item() { return null; } + plugin.addSidebarFooterItem({ id: "main", title: "One", Component: Item }); + plugin.addSidebarFooterItem({ id: "main", title: "Two", Component: Item }); + `), + ), + ).toThrow("Duplicate sidebar footer item: main"); + expect(() => + evaluatePluginClientBundle( + "example", + bundle(`plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component: "Main" });`), + ), + ).toThrow("Sidebar item main is not a component"); + expect(() => + evaluatePluginClientBundle( + "example", + bundle( + `plugin.addSidebarHeaderItem({ id: "main", title: " ", Component() { return null; } });`, + ), + ), + ).toThrow("Sidebar item main has no title"); + }); + + it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => { const plugin = evaluatePluginClientBundle( "example", bundle(` function Surface() { return null; } plugin.addSurface("main", Surface); - plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); `), ); - expect(plugin.id).toBe("example"); - expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); - expect(plugin.sidebarItems).toEqual([ - { id: "main", title: "Example", icon: "Blocks", surface: "main" }, + expect(plugin.surfaces.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "main" }, ]); + expect(plugin.sidebarItems).toEqual({ header: [], footer: [] }); + expect(plugin.legacySidebarItems).toEqual([ + { id: "entry", title: "Example", icon: "Blocks", surface: "main" }, + ]); + }); + + it("releases an addSidebarItem alias's header id", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } }); + const remove = plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); + remove(); + plugin.addSidebarHeaderItem({ id: "entry", title: "Replacement", Component() { return null; } }); + `), + ); + + expect(plugin.sidebarItems.header.map((item) => item.title)).toEqual(["Replacement"]); + expect(plugin.legacySidebarItems).toEqual([]); + }); + + it("rejects a header item that reuses an addSidebarItem id", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } }); + plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); + plugin.addSidebarHeaderItem({ id: "entry", title: "Other", Component() { return null; } }); + `), + ), + ).toThrow("Duplicate sidebar header item: entry"); }); it("collects a declarative attachment source", () => { @@ -479,9 +587,9 @@ describe("evaluatePluginClientBundle", () => { const { Icon } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { - plugin.addSurface("main", function Surface() { + plugin.addScreen({ id: "main", title: "Main", Component: function Surface() { return Icon({ name: "Settings", size: 18, color: "#123456" }); - }); + } }); return function() {}; }; return module.exports; diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index b58f82b372..a2703b6ad2 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -21,13 +21,17 @@ import { type PluginClientContext, type PluginClientSlashCommandContribution, type PluginSidebarContribution, + type PluginScreenContribution, + type PluginScreenProps, + type PluginScreenTitle, + type PluginSidebarItemContribution, type PluginSurfaceProps, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, type PluginWorkspacePanelContribution, type PluginButtonRegistration, } from "@getpaseo/plugin/client"; -import type { EvaluatedPlugin } from "./types"; +import type { EvaluatedPlugin, PluginSidebarSection } from "./types"; import type { ComponentType } from "react"; import { resolvePluginIcon } from "./icons"; import { pluginReactNativeRuntime } from "./react-native/runtime"; @@ -65,17 +69,33 @@ function normalizePanelLocations( return normalized; } +function isComponentType(value: unknown): boolean { + return ( + typeof value === "function" || + (typeof value === "object" && value !== null && "$$typeof" in value) + ); +} + function requireId(value: string, label: string): string { const id = value.trim(); if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); return id; } +/** A non-empty string, or a function of the screen's params resolved when the header renders. */ +function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { + if (typeof title === "function") return title as PluginScreenTitle; + if (typeof title === "string" && title.trim()) return title.trim(); + throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`); +} + export type PluginClientRuntime = Pick< PluginClientContext, + | "playAudio" | "paseo" | "rpc" | "openSettings" + | "openScreen" | "openSurface" | "openPanel" | "addComposerPill" @@ -91,7 +111,8 @@ export function runPluginClientBundle( const collector: Omit = { surfaces: [], settingsScreens: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -102,7 +123,10 @@ export function runPluginClientBundle( }; const surfaceIds = new Set(); const settingsScreenIds = new Set(); - const sidebarItemIds = new Set(); + const sidebarItemIds: Record> = { + header: new Set(), + footer: new Set(), + }; const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -148,6 +172,26 @@ export function runPluginClientBundle( removals.add(remove); return remove; } + function addSidebarItem( + section: PluginSidebarSection, + contribution: PluginSidebarItemContribution, + ): PluginCleanup { + const ids = sidebarItemIds[section]; + const normalizedId = requireId(contribution.id, `sidebar ${section} item id`); + if (ids.has(normalizedId)) + throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`); + const title = contribution.title.trim(); + if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`); + if (!isComponentType(contribution.Component)) { + throw new Error(`Sidebar item ${normalizedId} is not a component`); + } + ids.add(normalizedId); + return register( + collector.sidebarItems[section], + { id: normalizedId, title, Component: contribution.Component }, + () => ids.delete(normalizedId), + ); + } const pluginContext: PluginClientContext = { ...runtime, addSettingsScreen(contribution) { @@ -161,34 +205,56 @@ export function runPluginClientBundle( settingsScreenIds.delete(screenId), ); }, - addSurface(surfaceId: string, Component: ComponentType) { - const normalizedId = requireId(surfaceId, "surface id"); - if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); - if (typeof Component !== "function") - throw new Error(`Surface ${normalizedId} is not a component`); + addScreen(contribution: PluginScreenContribution) { + if (typeof contribution !== "object" || contribution === null) { + throw new Error("addScreen takes { id, title, Component }"); + } + const normalizedId = requireId(contribution.id, "screen id"); + if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate screen: ${normalizedId}`); + const title = requireScreenTitle(normalizedId, contribution.title); + if (!isComponentType(contribution.Component)) { + throw new Error(`Screen ${normalizedId} is not a component`); + } surfaceIds.add(normalizedId); - return register(collector.surfaces, { id: normalizedId, Component }, () => - surfaceIds.delete(normalizedId), + return register( + collector.surfaces, + { id: normalizedId, title, Component: contribution.Component }, + () => surfaceIds.delete(normalizedId), ); }, + addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { + return addSidebarItem("header", contribution); + }, + addSidebarFooterItem(contribution: PluginSidebarItemContribution) { + return addSidebarItem("footer", contribution); + }, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + // Titled by its id; a legacy item pointing at it lends the header its own title instead. + addSurface(surfaceId: string, Component: ComponentType) { + if (!isComponentType(Component)) throw new Error(`Screen ${surfaceId} is not a component`); + return pluginContext.addScreen({ + id: surfaceId, + title: surfaceId, + Component: (props: PluginScreenProps) => React.createElement(Component, props), + }); + }, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 addSidebarItem(contribution: PluginSidebarContribution) { const normalizedId = requireId(contribution.id, "sidebar item id"); - if (sidebarItemIds.has(normalizedId)) - throw new Error(`Duplicate sidebar item: ${normalizedId}`); - if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`); if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); resolvePluginIcon(contribution.icon.trim()); - sidebarItemIds.add(normalizedId); - return register( - collector.sidebarItems, - { - id: normalizedId, - title: contribution.title.trim(), - icon: contribution.icon.trim(), - surface: requireId(contribution.surface, "sidebar surface id"), - }, - () => sidebarItemIds.delete(normalizedId), - ); + const legacy = { + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }; + // Shares the header's ids: the sidebar orders both kinds of item under one key. + const ids = sidebarItemIds.header; + if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar header item: ${normalizedId}`); + if (!legacy.title) throw new Error(`Sidebar item ${normalizedId} has no title`); + ids.add(normalizedId); + return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId)); }, addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { const normalizedId = requireId(contribution.id, "workspace panel id"); @@ -411,7 +477,7 @@ export function runPluginClientBundle( entryCleanup = setup(pluginContext); if (typeof entryCleanup !== "function") throw new Error(`Plugin ${id} contribution must return a cleanup function`); - for (const item of collector.sidebarItems) { + for (const item of collector.legacySidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); } @@ -448,6 +514,7 @@ export function runPluginClientBundle( surfaces: collector.surfaces, settingsScreens: collector.settingsScreens, sidebarItems: collector.sidebarItems, + legacySidebarItems: collector.legacySidebarItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx new file mode 100644 index 0000000000..949bc7480f --- /dev/null +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -0,0 +1,71 @@ +import { usePaseo, useRpc } from "@getpaseo/plugin/client"; +import { defineRpc } from "@getpaseo/plugin"; +import type { PaseoApi } from "@getpaseo/client"; +import { QueryClient } from "@tanstack/react-query"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { PluginInstallationProvider } from "./installation-provider"; +import type { InstalledPlugin } from "./types"; + +const status = defineRpc({ + name: "deploys.status", + input: z.object({}), + output: z.object({ state: z.string() }), +}); + +function installation(invoked: string[]): InstalledPlugin { + return { + id: "deploys", + serverId: "host", + clientBundle: "", + lifetime: new AbortController(), + paseo: { dispose: async () => undefined } as PaseoApi, + invoke: async (method) => { + invoked.push(method); + return { state: "green" }; + }, + queryClient: new QueryClient(), + cleanup: () => undefined, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +it("gives every surface the installation's client and RPCs on its first frame", async () => { + const invoked: string[] = []; + const plugin = installation(invoked); + const received: PaseoApi[] = []; + let rpc: ((input: Record) => Promise) | null = null; + function Surface({ name }: { name: string }) { + received.push(usePaseo()); + rpc = useRpc(status); + return {name}; + } + + const markup = ["Screen", "Sidebar item", "Popover"] + .map((name) => + renderToStaticMarkup( + + + , + ), + ) + .join(""); + + expect(markup).toBe("ScreenSidebar itemPopover"); + expect(received).toEqual([plugin.paseo, plugin.paseo, plugin.paseo]); + expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true); + await expect(rpc!({})).resolves.toEqual({ state: "green" }); + expect(invoked).toEqual(["deploys.status"]); +}); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx new file mode 100644 index 0000000000..40d06ef035 --- /dev/null +++ b/packages/app/src/plugins/installation-provider.tsx @@ -0,0 +1,21 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; +import React, { type ReactNode } from "react"; +import type { InstalledPlugin } from "./types"; + +/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */ +export function PluginInstallationProvider({ + plugin, + children, +}: { + plugin: InstalledPlugin; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index 17e277504c..c1a50a98fc 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -23,8 +23,10 @@ export function createPluginNavigation(input: { openSettings(pluginId, screenId) { router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); }, - openSurface(pluginId, surfaceId) { - router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId })); + openSurface(pluginId, surfaceId, params) { + router.push( + buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }, params), + ); }, openWorkspacePanel(pluginId, panelId, location) { if (!workspaceId) throw new Error("No active workspace"); diff --git a/packages/app/src/plugins/platform.ts b/packages/app/src/plugins/platform.ts new file mode 100644 index 0000000000..816095e255 --- /dev/null +++ b/packages/app/src/plugins/platform.ts @@ -0,0 +1,9 @@ +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { Platform } from "react-native"; + +/** The platform plugins see in `layout.platform`. */ +export function resolvePluginPlatform(): PluginHostProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} diff --git a/packages/app/src/plugins/popover.tsx b/packages/app/src/plugins/popover.tsx new file mode 100644 index 0000000000..f9680ebb29 --- /dev/null +++ b/packages/app/src/plugins/popover.tsx @@ -0,0 +1,59 @@ +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuSurface, type MenuSurfaceProps } from "@/components/ui/menu"; +import { ToastApiProvider, type useToast } from "@/contexts/toast-context"; +import type { createPluginClientStateSource } from "./client-state/source"; +import { PluginInstallationProvider } from "./installation-provider"; +import type { InstalledPlugin } from "./types"; + +export interface PluginEnvironment { + installation: InstalledPlugin; + state: ReturnType; + toast: ReturnType; +} + +interface PluginEnvironmentProviderProps { + environment: PluginEnvironment; + children: ReactNode; +} + +/** + * The providers plugin UI runs under. Render it around a contribution and again inside every + * surface it opens: native sheets teleport their children, so providers around the trigger alone + * cannot reach a popover body. + */ +export function PluginEnvironmentProvider({ + environment, + children, +}: PluginEnvironmentProviderProps) { + return ( + + + {children} + + + ); +} + +type PluginPopoverSurfaceProps = Pick< + MenuSurfaceProps, + "children" | "pages" | "sheetTitle" | "sheetTrailing" | "side" | "align" | "offset" | "testID" +>; + +/** + * The surface every plugin popover opens in: anchored to its trigger on wide layouts, a bottom + * sheet on compact ones when the enclosing `MenuRoot` uses `compactMode="sheet"`. + */ +export function PluginPopoverSurface(props: PluginPopoverSurfaceProps) { + return ; +} + +export function PluginPopoverContent({ children }: { children: ReactNode }) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + content: { padding: theme.spacing[3], gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index 75ab40584c..1d7a72011f 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -10,3 +10,5 @@ export { } from "@/components/settings"; export { ExternalLink } from "@/components/ui/external-link"; + +export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit"; diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 526ce1a809..ecec420e3c 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,16 +1,37 @@ import { createPluginHosts } from "./hosts"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PaseoApi } from "@getpaseo/client"; import { expect, test } from "vitest"; -import { createPaseoApi } from "@getpaseo/client"; -import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; 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(); + const client = { + observeEvents(events: string[]) { + const [name] = events; + open.add(name); + return { + ready: Promise.resolve({ subscriptionId: name }), + subscribe: () => () => undefined, + release: async () => { + open.delete(name); + }, + }; + }, + invokePluginRpc: async () => null, + } as unknown as DaemonClient; + return { client, open }; +} + function registry() { - const client = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "plugin-lifetime-test" }); - const released: string[] = []; + const setupClients = new Map(); const plugins = new PluginRegistry({ version: "0.8.0", createRuntime: (installation) => { - const api = createPaseoApi(client); + setupClients.set(installation.id, installation.paseo); return { hosts: createPluginHosts( { @@ -21,54 +42,96 @@ function registry() { }, installation.lifetime.signal, ), - paseo: { - ...api, - dispose: async () => { - released.push(installation.id); - await api.dispose(); - }, - }, + paseo: installation.paseo, rpc: async () => { throw new Error("Unexpected plugin RPC"); }, + openScreen: () => {}, openSurface: () => {}, openSettings: () => {}, + playAudio: async () => {}, openPanel: () => {}, addComposerPill: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }), }; }, }); - return { client, released, plugins }; + return { ...observingClient(), plugins, setupClients }; } -function catalog(id: string, body: string) { +/** A plugin whose setup opens an observation named after it and never releases it. */ +function catalog(id: string, body = "return function() {};") { return { id, requirements: { paseo: ">=0.8.0" }, - clientBundle: `(function() { return { default: function(plugin) { ${body} } }; })`, + clientBundle: `(function() { return { default: function(plugin) { plugin.paseo.observeEvents(["${id}"]); ${body} } }; })`, }; } -test("failed plugin initialization disposes its API scope", async () => { +test("setup and every surface share the installation's one Paseo client", () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio }); + + const [installation] = h.plugins.getSnapshot(); + expect(h.setupClients.get("deploys")).toBe(installation.paseo); + expect(h.open).toEqual(new Set(["deploys"])); +}); + +test("teardown ends every subscription the plugin still holds", async () => { + const h = registry(); + 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, audio }); + await expect.poll(() => [...h.open]).toEqual(["kept"]); + + h.plugins.removeHost("host"); + await expect.poll(() => h.open.size).toBe(0); +}); + +test("reloading a plugin ends the old installation's subscriptions", async () => { + const h = registry(); + 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", + }); + + const [second] = h.plugins.getSnapshot(); + expect(second).not.toBe(first); + expect(second.paseo).not.toBe(first.paseo); + await expect(first.paseo.dispose()).resolves.toBeUndefined(); + expect(() => first.paseo.observeEvents(["project.update"])).toThrow("Paseo API is disposed"); + expect(h.open).toEqual(new Set(["reloaded"])); +}); + +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.released).toEqual(["failed"]); + await expect.poll(() => h.open.size).toBe(0); expect(h.plugins.getSnapshot()).toEqual([]); }); -test("unloading releases the API even when plugin cleanup throws, preserving another plugin", async () => { +test("unloading ends the subscriptions even when plugin cleanup throws, preserving another plugin", async () => { 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 }); - await expect.poll(() => h.released).toEqual(["failing"]); + 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"); - await expect.poll(() => h.released).toEqual(["failing", "surviving"]); + await expect.poll(() => h.open.size).toBe(0); }); test("an invalid async client entry is disposed and its rejected continuation is observed", async () => { @@ -76,9 +139,9 @@ 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.released).toEqual(["async-entry"]); + await expect.poll(() => h.open.size).toBe(0); await new Promise((resolve) => setTimeout(resolve, 0)); expect(h.plugins.getSnapshot()).toEqual([]); }); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 8f096aeac9..eb141e94c2 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -1,9 +1,10 @@ import { createPluginHosts } from "./hosts"; import { afterEach, expect, it } from "vitest"; -import { createPaseoApi } from "@getpaseo/client"; 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) { @@ -23,14 +24,16 @@ function registry(version: string) { }, installation.lifetime.signal, ), - paseo: createPaseoApi(client), + paseo: installation.paseo, rpc: async () => { throw new Error("No RPC in this plugin"); }, openSettings() { cleanups++; }, + 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 2894784fc7..e349446bfe 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,10 +1,12 @@ +import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; +import { createPaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import { resolveAppVersion } from "@/utils/app-version"; import { createPluginClientRuntime } from "./client-runtime"; -import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; +import { runPluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; type CatalogPlugin = Awaited>[number]; @@ -40,6 +42,7 @@ export class PluginRegistry { options: { replacePluginId?: string; client: DaemonClient; + audio: Pick; }, ): boolean { const previous = this.byHost.get(serverId) ?? []; @@ -64,8 +67,7 @@ export class PluginRegistry { } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; - let runtime: PluginClientRuntime | undefined; - let lifetime: AbortController | undefined; + let installation: InstalledPlugin | undefined; try { if (!entry.clientBundle) return []; assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); @@ -76,9 +78,11 @@ export class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } - lifetime = new AbortController(); - const installation: InstalledPlugin = { - lifetime, + const client = options.client; + installation = { + lifetime: new AbortController(), + paseo: createPaseoApi(client), + invoke: (method, input) => client.invokePluginRpc(entry.id, method, input), id: entry.id, serverId, clientBundle: entry.clientBundle, @@ -87,7 +91,8 @@ export class PluginRegistry { cleanup: () => undefined, surfaces: [], settingsScreens: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -96,12 +101,12 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - runtime = this.dependencies.createRuntime(installation, options.client); + const runtime = this.dependencies.createRuntime(installation, options.audio); const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => this.publish(), ); Object.assign(installation, evaluated); - const paseo = runtime.paseo; + const paseo = installation.paseo; installation.cleanup = async () => { const results = await Promise.allSettled([paseo.dispose(), evaluated.cleanup()]); const failures = results.filter((result) => result.status === "rejected"); @@ -114,8 +119,8 @@ export class PluginRegistry { this.evaluationErrors.delete(key); return [installation]; } catch (error) { - lifetime?.abort(); - void runtime?.paseo + installation?.lifetime.abort(); + void installation?.paseo .dispose() .catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure)); this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); diff --git a/packages/app/src/plugins/routes.test.ts b/packages/app/src/plugins/routes.test.ts index 18c6bfb1a0..b05b55da6b 100644 --- a/packages/app/src/plugins/routes.test.ts +++ b/packages/app/src/plugins/routes.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildLegacyPluginSurfaceRedirectRoute, buildPluginSurfaceRoute } from "./routes"; +import { + buildLegacyPluginSurfaceRedirectRoute, + buildPluginSurfaceRoute, + parsePluginSurfaceRoute, + pluginScreenParamsFromRoute, +} from "./routes"; describe("buildPluginSurfaceRoute", () => { it("keeps direct surfaces and sidebar contributions in separate route namespaces", () => { @@ -11,9 +16,79 @@ describe("buildPluginSurfaceRoute", () => { ); }); + it("puts screen params in the query under their own prefix, encoded", () => { + expect( + buildPluginSurfaceRoute( + "local", + "bots", + { kind: "surface", id: "bot" }, + { botId: "bot 2", "a&b": "x=y" }, + ), + ).toBe("/h/local/plugin/bots/surface/bot?param.botId=bot%202¶m.a%26b=x%3Dy"); + expect(buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, {})).toBe( + "/h/local/plugin/bots/surface/bot", + ); + }); + it("redirects legacy plugin surface URLs to their sidebar contribution identity", () => { expect(buildLegacyPluginSurfaceRedirectRoute("host/one", "review", "overview/item")).toBe( "/h/host%2Fone/plugin/review/sidebar/overview%2Fitem", ); }); }); + +describe("parsePluginSurfaceRoute", () => { + it("reads both route namespaces back from the paths buildPluginSurfaceRoute produces", () => { + expect( + parsePluginSurfaceRoute( + buildPluginSurfaceRoute("host/one", "review", { kind: "surface", id: "overview" }), + ), + ).toEqual({ + serverId: "host/one", + pluginId: "review", + identity: { kind: "surface", id: "overview" }, + }); + expect(parsePluginSurfaceRoute("/h/local/plugin/review/sidebar/item")).toEqual({ + serverId: "local", + pluginId: "review", + identity: { kind: "sidebar", id: "item" }, + }); + }); + + it("ignores routes that are not plugin screens", () => { + expect(parsePluginSurfaceRoute("/h/local/workspace/abc")).toBeNull(); + expect(parsePluginSurfaceRoute("/h/local/plugin/review/settings/general")).toBeNull(); + }); +}); + +describe("pluginScreenParamsFromRoute", () => { + function searchParams(route: string): Record { + // Expo Router's search params: the route's segments merged with its query. + const url = new URL(route, "http://paseo.test"); + return { + serverId: "local", + pluginId: "bots", + contributionKind: "surface", + contributionId: "bot", + ...Object.fromEntries(url.searchParams), + }; + } + + it("round-trips params whose keys match the route's own segments", () => { + const params = { serverId: "x", pluginId: "y", botId: "bot-2" }; + const route = buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, params); + expect(pluginScreenParamsFromRoute(searchParams(route))).toEqual(params); + }); + + it("ignores the route's segments and non-string values", () => { + expect( + pluginScreenParamsFromRoute({ + serverId: "local", + contributionId: "bot", + "param.botId": "bot-2", + "param.tags": ["a", "b"], + "param.missing": undefined, + }), + ).toEqual({ botId: "bot-2" }); + }); +}); diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts index d4fbf2ebef..3c51fe4a3b 100644 --- a/packages/app/src/plugins/routes.ts +++ b/packages/app/src/plugins/routes.ts @@ -1,16 +1,58 @@ +import type { PluginScreenParams } from "@getpaseo/plugin/client"; import type { PluginSurfaceContributionIdentity } from "./surface-contribution"; type PluginSurfaceRoute = `/h/${string}/plugin/${string}/${Kind}/${string}`; +/** + * Expo Router merges the route's segments (`serverId`, `pluginId`, ...) into the same search params + * as the query. Prefixing screen param keys keeps any plugin key clear of them. + */ +const SCREEN_PARAM_PREFIX = "param."; + +/** Screen params ride in the route's query, so reload, history and deep links keep them. */ export function buildPluginSurfaceRoute( serverId: string, pluginId: string, identity: Identity, + params: PluginScreenParams = {}, ): PluginSurfaceRoute { - return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}` as PluginSurfaceRoute< - Identity["kind"] - >; + const path = `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}`; + const query = Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(SCREEN_PARAM_PREFIX + key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + return (query ? `${path}?${query}` : path) as PluginSurfaceRoute; +} + +/** Checks params a plugin passed to `openScreen`: an object of string values. Throws otherwise. */ +export function parsePluginScreenParams(value: unknown): PluginScreenParams { + if (value === undefined) return {}; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Plugin screen params must be an object of strings"); + } + const params: PluginScreenParams = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new Error(`Plugin screen param ${key} must be a string`); + } + params[key] = entry; + } + return params; +} + +/** The screen params in a plugin screen route's search params (`useLocalSearchParams`). */ +export function pluginScreenParamsFromRoute( + routeParams: Readonly>, +): PluginScreenParams { + const params: PluginScreenParams = {}; + for (const [key, value] of Object.entries(routeParams)) { + if (!key.startsWith(SCREEN_PARAM_PREFIX) || typeof value !== "string") continue; + params[key.slice(SCREEN_PARAM_PREFIX.length)] = value; + } + return params; } export function buildLegacyPluginSurfaceRedirectRoute( @@ -33,3 +75,21 @@ export function hostIdFromPathname(pathname: string): string | null { return null; } } + +export function parsePluginSurfaceRoute(pathname: string): { + serverId: string; + pluginId: string; + identity: PluginSurfaceContributionIdentity; +} | null { + const match = /^\/h\/([^/]+)\/plugin\/([^/]+)\/(sidebar|surface)\/([^/]+)\/?$/.exec(pathname); + if (!match) return null; + try { + return { + serverId: decodeURIComponent(match[1]), + pluginId: decodeURIComponent(match[2]), + identity: { kind: match[3] as "sidebar" | "surface", id: decodeURIComponent(match[4]) }, + }; + } catch { + return null; + } +} diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx deleted file mode 100644 index 7ae51af2b9..0000000000 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; -import type { ReactNode } from "react"; -import type { InstalledPlugin } from "./types"; -import { usePluginSurfaceRuntime } from "./surface-runtime"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; - -export function PluginRuntimeBoundary({ - plugin, - client, - children, -}: { - plugin: InstalledPlugin; - client: DaemonClient; - children: ReactNode; -}) { - const runtime = usePluginSurfaceRuntime(client, plugin); - if (!runtime) return null; - return ( - - - {children} - - - ); -} diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 11b5c199df..0fda205671 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -12,7 +12,7 @@ import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; import { useInstalledPlugin } from "../registry"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; import { buildPluginSettingsRoute } from "./routes"; @@ -119,14 +119,15 @@ function SettingsContent({ resetKey={attempt} renderError={renderError} > - + - + ); } const ThemedSettingsContent = withUnistyles(SettingsContent); +// Settings routes carry no screen params. const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); export function PluginSettingsContent({ onBackToPlugins, diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 7a38bb8eb5..314e5fe1e7 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -1,26 +1,35 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; -import { groupPluginSidebarContributions } from "./sidebar-groups"; +import { + legacySidebarItemHostKey, + pluginScreensHostKey, + rememberPluginContributionHost, +} from "./contribution-host"; +import { groupPluginSidebarItems, selectPluginSidebarTarget } from "./sidebar-groups"; -function installed(serverId: string, contributionId = "main"): InstalledPlugin { +function installed( + serverId: string, + contributionId = "main", + legacySidebarItems: InstalledPlugin["legacySidebarItems"] = [], + pluginId = "example", +): InstalledPlugin { return { - id: "example", + id: pluginId, cleanup: () => undefined, serverId, clientBundle: serverId, lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, settingsScreens: [], - surfaces: [{ id: "surface", Component: () => null }], - sidebarItems: [ - { - id: contributionId, - title: "Example", - icon: "Blocks", - surface: "surface", - }, - ], + surfaces: [{ id: "surface", title: "Surface", Component: () => null }], + sidebarItems: { + header: [{ id: contributionId, title: "Example", Component: () => null }], + footer: [{ id: "status", title: "Status", Component: () => null }], + }, + legacySidebarItems, workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -31,9 +40,9 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { }; } -describe("groupPluginSidebarContributions", () => { +describe("groupPluginSidebarItems", () => { it("coalesces the same plugin contribution across hosts", () => { - const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]); + const groups = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "header"); expect(groups).toHaveLength(1); expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([ @@ -43,14 +52,66 @@ describe("groupPluginSidebarContributions", () => { }); it("keeps different contribution ids separate", () => { - const groups = groupPluginSidebarContributions([ - installed("host-a", "main"), - installed("host-b", "settings"), - ]); + const groups = groupPluginSidebarItems( + [installed("host-a", "main"), installed("host-b", "settings")], + "header", + ); expect(groups.map((group) => group.key)).toEqual([ "example/sidebar/main", "example/sidebar/settings", ]); }); + + it("keeps a legacy addSidebarItem as its own kind, with its icon, in the header only", () => { + const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }]; + const plugins = [installed("host-a", "main", legacy), installed("host-b", "main", legacy)]; + const [legacyGroup, itemGroup] = groupPluginSidebarItems(plugins, "header"); + + expect(legacyGroup).toMatchObject({ kind: "legacy", title: "Legacy", icon: "Blocks" }); + expect(legacyGroup?.targets.map((target) => target.plugin.serverId)).toEqual([ + "host-a", + "host-b", + ]); + expect(itemGroup?.kind).toBe("item"); + expect(groupPluginSidebarItems(plugins, "footer").map((group) => group.kind)).toEqual(["item"]); + }); + + it("groups only the requested section", () => { + const groups = groupPluginSidebarItems([installed("host-a")], "footer"); + + expect(groups.map((group) => group.key)).toEqual(["example/sidebar-footer/status"]); + }); +}); + +describe("selectPluginSidebarTarget", () => { + const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }]; + + function hostsOf(section: "header" | "footer", pluginId: string) { + const plugins = ["host-a", "host-b"].map((serverId) => + installed(serverId, "main", legacy, pluginId), + ); + return groupPluginSidebarItems(plugins, section).map( + (group) => selectPluginSidebarTarget(group, null).plugin.serverId, + ); + } + + it("prefers the current route's host", () => { + const [group] = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "footer"); + expect(selectPluginSidebarTarget(group!, "host-b").plugin.serverId).toBe("host-b"); + }); + + it("carries the host picked on any of a plugin's screens to all its items, but not legacy rows", () => { + expect(hostsOf("header", "screens")).toEqual(["host-a", "host-a"]); + rememberPluginContributionHost(pluginScreensHostKey("screens"), "host-b"); + // Header: the legacy row keeps its own host; the item follows the screens' host. + expect(hostsOf("header", "screens")).toEqual(["host-a", "host-b"]); + expect(hostsOf("footer", "screens")).toEqual(["host-b"]); + expect(hostsOf("footer", "other")).toEqual(["host-a"]); + }); + + it("keeps a legacy row's host under its own key", () => { + rememberPluginContributionHost(legacySidebarItemHostKey("legacy-row", "entry"), "host-b"); + expect(hostsOf("header", "legacy-row")).toEqual(["host-b", "host-a"]); + }); }); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index 05c868f283..c764a53dcc 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -1,29 +1,65 @@ -import type { InstalledPlugin, PluginSidebarContribution } from "./types"; +import { + getPreferredPluginContributionHost, + legacySidebarItemHostKey, + pluginScreensHostKey, +} from "./contribution-host"; +import type { + InstalledPlugin, + PluginSidebarContribution, + PluginSidebarItemContribution, + PluginSidebarSection, +} from "./types"; export interface PluginSidebarTarget { + plugin: InstalledPlugin; + item: PluginSidebarItemContribution; +} + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +export interface PluginLegacySidebarTarget { plugin: InstalledPlugin; item: PluginSidebarContribution; } -export interface PluginSidebarGroup { +interface PluginSidebarGroupBase { key: string; pluginId: string; contributionId: string; title: string; - icon: string; - targets: PluginSidebarTarget[]; } -export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] { +/** + * One sidebar item, coalesced across every host that contributes it. A `legacy` group is an + * `addSidebarItem` row: the app draws it with its registered icon and it opens its sidebar route. + */ +export type PluginSidebarGroup = + | (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] }) + | (PluginSidebarGroupBase & { + kind: "legacy"; + icon: string; + targets: PluginLegacySidebarTarget[]; + }); + +function groupKey(pluginId: string, section: PluginSidebarSection, itemId: string) { + return section === "header" + ? legacySidebarItemHostKey(pluginId, itemId) + : `${pluginId}/sidebar-${section}/${itemId}`; +} + +export function groupPluginSidebarItems( + plugins: InstalledPlugin[], + section: PluginSidebarSection, +): PluginSidebarGroup[] { const groups = new Map(); for (const plugin of plugins) { - for (const item of plugin.sidebarItems) { - const key = `${plugin.id}/sidebar/${item.id}`; + const legacyItems = section === "header" ? plugin.legacySidebarItems : []; + for (const item of legacyItems) { + const key = groupKey(plugin.id, section, item.id); const existing = groups.get(key); - if (existing) { - existing.targets.push({ plugin, item }); - } else { + if (existing?.kind === "legacy") existing.targets.push({ plugin, item }); + else if (!existing) { groups.set(key, { + kind: "legacy", key, pluginId: plugin.id, contributionId: item.id, @@ -33,6 +69,41 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu }); } } + for (const item of plugin.sidebarItems[section]) { + const key = groupKey(plugin.id, section, item.id); + const existing = groups.get(key); + if (existing?.kind === "item") existing.targets.push({ plugin, item }); + else if (!existing) { + groups.set(key, { + kind: "item", + key, + pluginId: plugin.id, + contributionId: item.id, + title: item.title, + targets: [{ plugin, item }], + }); + } + } } return [...groups.values()]; } + +/** + * The host a group's row uses: the current route's host, else the remembered one, else the first. + * A legacy row remembers its own host; other items share the host last used with the plugin's + * screens. + */ +export function selectPluginSidebarTarget( + group: Group, + currentHostId: string | null, +): Group["targets"][number] { + const rememberedHostId = getPreferredPluginContributionHost( + group.kind === "legacy" ? group.key : pluginScreensHostKey(group.pluginId), + ); + const targets: Group["targets"] = group.targets; + return ( + targets.find((target) => target.plugin.serverId === currentHostId) ?? + targets.find((target) => target.plugin.serverId === rememberedHostId) ?? + targets[0] + ); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx deleted file mode 100644 index 97c0c6fa6d..0000000000 --- a/packages/app/src/plugins/sidebar-items.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { router, usePathname } from "expo-router"; -import { useCallback } from "react"; -import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; -import { resolvePluginIcon } from "./icons"; -import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes"; -import { - getPreferredPluginContributionHost, - rememberPluginContributionHost, -} from "./contribution-host"; -import { type PluginSidebarGroup, type PluginSidebarTarget } from "./sidebar-groups"; - -function selectTarget( - group: PluginSidebarGroup, - currentHostId: string | null, -): PluginSidebarTarget { - const current = group.targets.find((target) => target.plugin.serverId === currentHostId); - if (current) return current; - const rememberedHostId = getPreferredPluginContributionHost(group.key); - const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId); - return remembered ?? group.targets[0]; -} - -export function PluginSidebarItemRow({ - group, - onBeforeNavigate, -}: { - group: PluginSidebarGroup; - onBeforeNavigate?: () => void; -}) { - const pathname = usePathname(); - const target = selectTarget(group, hostIdFromPathname(pathname)); - const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, { - kind: "sidebar", - id: group.contributionId, - }); - const isActive = group.targets.some( - (candidate) => - pathname === - buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, { - kind: "sidebar", - id: group.contributionId, - }), - ); - const navigate = useCallback(() => { - rememberPluginContributionHost(group.key, target.plugin.serverId); - onBeforeNavigate?.(); - router.push(route); - }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); - return ( - - ); -} diff --git a/packages/app/src/plugins/sidebar-items/frame.ts b/packages/app/src/plugins/sidebar-items/frame.ts new file mode 100644 index 0000000000..f5f42e5d56 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/frame.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import type { View } from "react-native"; +import type { PluginSidebarSection } from "../types"; + +/** What the host tells the kit components about the sidebar item they render in. */ +export interface SidebarItemFrame { + section: PluginSidebarSection; + title: string; + testID: string; + /** A row calls this before its `onPress`, so a popover that press opens anchors to it. */ + anchorTo: (node: View | null) => void; + /** Rows offer themselves on mount, so a popover opened without a press anchors to a row. */ + offerAnchor: (node: View | null) => void; + releaseAnchor: (node: View | null) => void; +} + +export const SidebarItemFrameContext = createContext(null); + +export function useSidebarItemFrame(componentName: string): SidebarItemFrame { + const frame = useContext(SidebarItemFrameContext); + if (!frame) throw new Error(`${componentName} must render inside a sidebar item`); + return frame; +} diff --git a/packages/app/src/plugins/sidebar-items/index.tsx b/packages/app/src/plugins/sidebar-items/index.tsx new file mode 100644 index 0000000000..11657c5d35 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/index.tsx @@ -0,0 +1,245 @@ +import type { + PluginHostProps, + PluginOpenScreenInput, + PluginPopoverProps, + PluginScreenLocation, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { router, useGlobalSearchParams, usePathname } from "expo-router"; +import { useCallback, useMemo, useState, type ComponentType, type RefObject } from "react"; +import { type View } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { + SidebarPopoverRoot, + SidebarPopoverSurface, + useSidebarPopoverAnchor, +} from "@/components/sidebar/sidebar-popover"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { pluginScreensHostKey, rememberPluginContributionHost } from "../contribution-host"; +import { + PluginEnvironmentProvider, + PluginPopoverContent, + type PluginEnvironment, +} from "../popover"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes"; +import { + selectPluginSidebarTarget, + type PluginSidebarGroup, + type PluginSidebarTarget, +} from "../sidebar-groups"; +import { currentPluginScreen, parsePluginOpenScreenInput } from "../surface-contribution"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import type { PluginSidebarSection } from "../types"; +import { SidebarItemFrameContext, type SidebarItemFrame } from "./frame"; +import { LegacyPluginSidebarRow } from "./legacy"; +import { resolvePluginPlatform } from "../platform"; + +export { SidebarRow, SidebarSeparator } from "./kit"; + +type PopoverContent = ComponentType; + +const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +type PluginItemGroup = Extract; + +function renderNothing() { + return null; +} + +interface PluginSidebarItemEntryProps { + group: PluginSidebarGroup; + section: PluginSidebarSection; + /** Anchors a popover opened by an item that renders no kit component. */ + fallbackAnchorRef: RefObject; + onBeforeNavigate?: () => void; +} + +type PluginSidebarItemHostProps = Omit & { + group: PluginItemGroup; +}; + +/** + * One plugin sidebar item. Renders the item's `Component` from the host `selectPluginSidebarTarget` picks, + * inside the plugin's runtime and its own error boundary: a throwing item renders nothing. A legacy + * `addSidebarItem` group renders the app's own row. + */ +export function PluginSidebarItem({ group, ...props }: PluginSidebarItemEntryProps) { + if (group.kind === "legacy") { + return ; + } + return ; +} + +function PluginSidebarItemHost({ + group, + section, + fallbackAnchorRef, + onBeforeNavigate, + theme, +}: PluginSidebarItemHostProps & { theme: PluginTheme }) { + const pathname = usePathname(); + const routeParams = useGlobalSearchParams(); + const { plugin, item } = selectPluginSidebarTarget(group, hostIdFromPathname(pathname)); + const client = useHostRuntimeClient(plugin.serverId); + const toast = useToast(); + // The content outlives `open` so the surface can play its exit and native sheet teardown. + const [popover, setPopover] = useState(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const environment = useMemo( + () => + client + ? { + installation: plugin, + toast, + state: createPluginClientStateSource(plugin.serverId), + } + : null, + [client, plugin, toast], + ); + const showPopover = useCallback((content: PopoverContent | null) => { + // A function passed to a state setter is an updater, so wrap the component. + if (content) setPopover(() => content); + setPopoverOpen(content !== null); + }, []); + const handleOpenChange = useCallback((open: boolean) => { + if (!open) setPopoverOpen(false); + }, []); + if (!environment) return null; + return ( + + + + + + + + ); +} + +const ThemedPluginSidebarItem = withUnistyles(PluginSidebarItemHost); + +function SidebarItemContent({ + plugin, + item, + section, + theme, + currentScreen, + environment, + popover, + showPopover, + fallbackAnchorRef, + onBeforeNavigate, +}: { + plugin: PluginSidebarTarget["plugin"]; + item: PluginSidebarTarget["item"]; + section: PluginSidebarSection; + theme: PluginTheme; + currentScreen: PluginScreenLocation | null; + environment: PluginEnvironment; + popover: PopoverContent | null; + showPopover: (content: PopoverContent | null) => void; + fallbackAnchorRef: RefObject; + onBeforeNavigate?: () => void; +}) { + const { anchorTo, offerAnchor, releaseAnchor, anchorToFallback } = + useSidebarPopoverAnchor("PluginSidebarItem"); + const hosts = useHosts(); + const compact = useIsCompactFormFactor(); + const hostLabel = + hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId; + + const hostProps = useMemo( + () => ({ + theme, + host: { id: plugin.serverId, label: hostLabel }, + layout: { compact, platform: resolvePluginPlatform() }, + }), + [compact, hostLabel, plugin.serverId, theme], + ); + const openScreen = useCallback( + (input: PluginOpenScreenInput) => { + const { screenId, params } = parsePluginOpenScreenInput(plugin, input); + rememberPluginContributionHost(pluginScreensHostKey(plugin.id), plugin.serverId); + showPopover(null); + onBeforeNavigate?.(); + router.push( + buildPluginSurfaceRoute( + plugin.serverId, + plugin.id, + { kind: "surface", id: screenId }, + params, + ), + ); + }, + [onBeforeNavigate, plugin, showPopover], + ); + const openPopover = useCallback( + (Content: PopoverContent) => { + anchorToFallback(fallbackAnchorRef); + showPopover(Content); + }, + [anchorToFallback, fallbackAnchorRef, showPopover], + ); + const close = useCallback(() => showPopover(null), [showPopover]); + const frame = useMemo( + () => ({ + section, + title: item.title, + testID: + section === "header" + ? `plugin-sidebar-${plugin.id}-${item.id}` + : `plugin-sidebar-${section}-${plugin.id}-${item.id}`, + anchorTo, + offerAnchor, + releaseAnchor, + }), + [anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section], + ); + const itemProps = useMemo( + () => ({ ...hostProps, currentScreen, openScreen, openPopover }), + [currentScreen, hostProps, openPopover, openScreen], + ); + const Item = item.Component; + const Popover = popover; + return ( + + + {Popover ? ( + + + + + + + + + + ) : null} + + ); +} diff --git a/packages/app/src/plugins/sidebar-items/kit.tsx b/packages/app/src/plugins/sidebar-items/kit.tsx new file mode 100644 index 0000000000..5796d572ca --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/kit.tsx @@ -0,0 +1,72 @@ +import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui"; +import { useCallback, useEffect, useRef } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { resolvePluginIcon } from "../icons"; +import { useSidebarItemFrame } from "./frame"; + +function resolveIcon(icon: SidebarIcon) { + return typeof icon === "string" ? resolvePluginIcon(icon) : icon; +} + +/** + * An item may render several rows. Any press inside a row, on the row or on its trailing content, + * makes that row the popover anchor: the capture-phase responder check runs before the pressed + * child claims the touch (native and web), and returns false so the child still handles it. + */ +export function SidebarRow({ id, icon, label, onPress, active, trailing }: SidebarRowProps) { + const frame = useSidebarItemFrame("SidebarRow"); + const { anchorTo, offerAnchor, releaseAnchor } = frame; + const rowRef = useRef(null); + useEffect(() => { + const node = rowRef.current; + offerAnchor(node); + return () => releaseAnchor(node); + }, [offerAnchor, releaseAnchor]); + const anchorHere = useCallback(() => { + anchorTo(rowRef.current); + return false; + }, [anchorTo]); + // Keyboard activation skips the responder system, so the row's own press anchors as well. + const handlePress = useCallback(() => { + anchorTo(rowRef.current); + onPress(); + }, [anchorTo, onPress]); + return ( + + + + ); +} + +/** The sidebar's separator line, run edge to edge across the section like the app's own. */ +export function SidebarSeparator() { + const frame = useSidebarItemFrame("SidebarSeparator"); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Header rows fill the sidebar width; the gap between them is 2px. + header: { + marginVertical: theme.spacing[1], + }, + // The footer pads its rows by `spacing[2]` and spaces them `spacing[1]` apart. + footer: { + marginHorizontal: -theme.spacing[2], + marginVertical: theme.spacing[0.5], + }, +})); diff --git a/packages/app/src/plugins/sidebar-items/legacy.tsx b/packages/app/src/plugins/sidebar-items/legacy.tsx new file mode 100644 index 0000000000..94d3e69029 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/legacy.tsx @@ -0,0 +1,44 @@ +import { router, usePathname } from "expo-router"; +import { useCallback } from "react"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { rememberPluginContributionHost } from "../contribution-host"; +import { resolvePluginIcon } from "../icons"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes"; +import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups"; + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +/** + * An `addSidebarItem({ id, title, icon, surface })` row. It opens `/sidebar/`, so the screen's + * header and host switcher belong to this item. + */ +export function LegacyPluginSidebarRow({ + group, + onBeforeNavigate, +}: { + group: Extract; + onBeforeNavigate?: () => void; +}) { + const pathname = usePathname(); + const identity = { kind: "sidebar", id: group.contributionId } as const; + const target = selectPluginSidebarTarget(group, hostIdFromPathname(pathname)); + const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, identity); + const isActive = group.targets.some( + (candidate) => + pathname === buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, identity), + ); + const navigate = useCallback(() => { + rememberPluginContributionHost(group.key, target.plugin.serverId); + onBeforeNavigate?.(); + router.push(route); + }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); + return ( + + ); +} diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index e6b2eea757..90672ed435 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -2,7 +2,10 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; import { + currentPluginScreen, getPluginSurfaceContributionServerIds, + parsePluginOpenScreenInput, + resolvePluginScreenTitle, resolvePluginSurfaceContribution, } from "./surface-contribution"; @@ -18,10 +21,13 @@ function installation( clientBundle: serverId, lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => undefined, settingsScreens: [], - surfaces: surfaces.map((id) => ({ id, Component: () => null })), - sidebarItems: sidebarItems.map((item) => ({ + surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })), + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: sidebarItems.map((item) => ({ ...item, title: item.id, icon: "Blocks", @@ -67,6 +73,7 @@ describe("plugin surface contribution identity", () => { expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([ "host-v1", "host-v2", + "same-surface", ]); }); @@ -82,4 +89,129 @@ describe("plugin surface contribution identity", () => { "same-surface", ]); }); + + it("opens a direct screen without borrowing a legacy item that points at it", () => { + const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, { + kind: "surface", + id: "surface-v1", + }); + + expect(resolved.sidebarItem).toBeNull(); + expect(resolved.surface?.id).toBe("surface-v1"); + }); + + it("resolves an old sidebar link to the same-id screen once the plugin has migrated", () => { + const resolved = resolvePluginSurfaceContribution(installations[2] ?? null, { + kind: "sidebar", + id: "overview", + }); + + expect(resolved.sidebarItem).toBeNull(); + expect(resolved.surface?.id).toBe("overview"); + expect( + resolvePluginSurfaceContribution(installations[2] ?? null, { kind: "sidebar", id: "missing" }) + .surface, + ).toBeNull(); + }); +}); + +describe("currentPluginScreen", () => { + const plugin = installation( + "local", + ["overview", "details"], + [{ id: "legacy", surface: "details" }], + ); + + it("reads the open screen and its params from direct and legacy routes on the installation's host", () => { + expect( + currentPluginScreen(plugin, "/h/local/plugin/review/surface/overview", { + serverId: "local", + pluginId: "review", + contributionKind: "surface", + contributionId: "overview", + "param.botId": "bot-2", + }), + ).toEqual({ screenId: "overview", params: { botId: "bot-2" } }); + expect(currentPluginScreen(plugin, "/h/local/plugin/review/sidebar/legacy", {})).toEqual({ + screenId: "details", + params: {}, + }); + }); + + it("is null on another host, another plugin, or a missing screen", () => { + expect(currentPluginScreen(plugin, "/h/remote/plugin/review/surface/overview", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/plugin/other/surface/overview", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/plugin/review/surface/missing", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/workspace/abc", {})).toBeNull(); + }); +}); + +describe("parsePluginOpenScreenInput", () => { + const plugin = installation("local", ["bot"]); + + it("accepts a registered screen with or without string params, under any key", () => { + expect(parsePluginOpenScreenInput(plugin, { screenId: "bot" })).toEqual({ + screenId: "bot", + params: {}, + }); + expect(parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: "2" } })).toEqual( + { screenId: "bot", params: { botId: "2" } }, + ); + expect( + parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { serverId: "x" } }), + ).toEqual({ screenId: "bot", params: { serverId: "x" } }); + }); + + it("throws on a bare id, an unknown screen, or params that are not strings", () => { + expect(() => parsePluginOpenScreenInput(plugin, "bot")).toThrow("{ screenId, params? }"); + expect(() => parsePluginOpenScreenInput(plugin, { screenId: "missing" })).toThrow( + "unavailable: missing", + ); + expect(() => + parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: 2 } }), + ).toThrow("botId must be a string"); + expect(() => parsePluginOpenScreenInput(plugin, { screenId: "bot", params: ["2"] })).toThrow( + "must be an object of strings", + ); + }); +}); + +describe("resolvePluginScreenTitle", () => { + const Component = () => null; + const botNames: Record = { "bot-1": "Bot 1", "bot-2": "Bot 2" }; + const bot = { + id: "bot", + title: (params: Record) => botNames[params.botId] ?? "", + Component, + }; + + it("uses a string title as is", () => { + expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe( + "Deploys", + ); + }); + + it("derives a function title from the params it is given", () => { + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1"); + }); + + it("falls back to the screen id when the function returns empty or throws", () => { + expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot"); + const throwing = { + id: "bot", + title: () => { + throw new Error("no bots loaded"); + }, + Component, + }; + expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot"); + }); + + it("uses the title of a legacy item that points at the screen", () => { + const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" }; + expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe( + "Legacy entry", + ); + }); }); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index 743ebbc1a7..830677f164 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -1,39 +1,100 @@ +import type { PluginScreenLocation, PluginScreenParams } from "@getpaseo/plugin/client"; +import { + parsePluginScreenParams, + parsePluginSurfaceRoute, + pluginScreenParamsFromRoute, +} from "./routes"; import type { InstalledPlugin } from "./types"; export type PluginSurfaceContributionIdentity = | { kind: "sidebar"; id: string } | { kind: "surface"; id: string }; +/** + * A `sidebar` identity is a legacy `addSidebarItem` route: it resolves to that item and its screen, + * or, once the plugin has moved that item to `addScreen` under the same id, to that screen. + */ export function resolvePluginSurfaceContribution( plugin: InstalledPlugin | null, identity: PluginSurfaceContributionIdentity | null, ): { - sidebarItem: InstalledPlugin["sidebarItems"][number] | null; + sidebarItem: InstalledPlugin["legacySidebarItems"][number] | null; surface: InstalledPlugin["surfaces"][number] | null; } { - if (!identity) return { sidebarItem: null, surface: null }; + if (!identity || !plugin) return { sidebarItem: null, surface: null }; const sidebarItem = identity.kind === "sidebar" - ? (plugin?.sidebarItems.find((contribution) => contribution.id === identity.id) ?? null) + ? (plugin.legacySidebarItems.find((contribution) => contribution.id === identity.id) ?? null) : null; - const surfaceId = identity.kind === "sidebar" ? sidebarItem?.surface : identity.id; + const surfaceId = sidebarItem ? sidebarItem.surface : identity.id; const surface = surfaceId - ? (plugin?.surfaces.find((contribution) => contribution.id === surfaceId) ?? null) + ? (plugin.surfaces.find((contribution) => contribution.id === surfaceId) ?? null) : null; return { sidebarItem, surface }; } +/** + * The screen header's title. A legacy `addSidebarItem` pointing at the screen lends its title, as + * before screens had one. A title function that throws or returns an empty string falls back to the + * screen id, so a bad title never takes the screen down. + */ +export function resolvePluginScreenTitle( + screen: InstalledPlugin["surfaces"][number], + legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null, + params: PluginScreenParams, +): string { + if (legacySidebarItem) return legacySidebarItem.title; + if (typeof screen.title === "string") return screen.title; + try { + const title = screen.title(params); + if (typeof title === "string" && title.trim()) return title.trim(); + } catch (error) { + console.warn(`[Plugins] Screen ${screen.id} title failed`, error); + } + return screen.id; +} + +/** + * The installation's screen open at `pathname` with the route's search params, or null when the + * route shows anything else. + */ +export function currentPluginScreen( + plugin: InstalledPlugin, + pathname: string, + routeParams: Readonly>, +): PluginScreenLocation | null { + const route = parsePluginSurfaceRoute(pathname); + if (!route || route.serverId !== plugin.serverId || route.pluginId !== plugin.id) return null; + const screenId = resolvePluginSurfaceContribution(plugin, route.identity).surface?.id; + return screenId ? { screenId, params: pluginScreenParamsFromRoute(routeParams) } : null; +} + +/** Checks an `openScreen` input against the installation's screens. Throws when it is invalid. */ +export function parsePluginOpenScreenInput( + plugin: InstalledPlugin, + input: unknown, +): PluginScreenLocation { + if (typeof input !== "object" || input === null || !("screenId" in input)) { + throw new Error("openScreen takes { screenId, params? }"); + } + const { screenId } = input; + if (typeof screenId !== "string" || !plugin.surfaces.some((surface) => surface.id === screenId)) { + throw new Error(`Plugin screen is unavailable: ${String(screenId)}`); + } + const params = "params" in input ? input.params : undefined; + return { screenId, params: parsePluginScreenParams(params) }; +} + export function getPluginSurfaceContributionServerIds( installations: readonly InstalledPlugin[], pluginId: string, identity: PluginSurfaceContributionIdentity, ): string[] { return installations - .filter((installation) => { - if (installation.id !== pluginId) return false; - return identity.kind === "sidebar" - ? installation.sidebarItems.some((contribution) => contribution.id === identity.id) - : installation.surfaces.some((contribution) => contribution.id === identity.id); - }) + .filter( + (installation) => + installation.id === pluginId && + resolvePluginSurfaceContribution(installation, identity).surface !== null, + ) .map((installation) => installation.serverId); } diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx deleted file mode 100644 index 30b2a7f76b..0000000000 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PaseoApi } from "@getpaseo/client"; -import { PaseoApiProvider } from "@getpaseo/plugin/client/host"; -import { usePaseo } from "@getpaseo/plugin/client"; -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; - -function clientWithWorkspace(id: string) { - const createWorkspace = vi.fn(async () => ({ - error: null, - workspace: { - id, - projectId: `project-${id}`, - workspaceDirectory: `/tmp/${id}`, - name: id, - status: "active", - }, - })); - const createAgent = vi.fn(async () => ({ - id: `agent-${id}`, - provider: "codex", - cwd: `/tmp/${id}`, - workspaceId: id, - status: "idle", - })); - const invokePluginRpc = vi.fn(async () => id); - return { - client: { createWorkspace, createAgent, invokePluginRpc } as unknown as DaemonClient, - createWorkspace, - createAgent, - invokePluginRpc, - }; -} - -function borrowFromAppProvider(paseo: PaseoApi): PaseoApi { - let borrowed: PaseoApi | null = null; - function PluginSurface() { - borrowed = usePaseo(); - return null; - } - renderToStaticMarkup( - - - , - ); - if (!borrowed) throw new Error("Plugin surface did not receive Paseo API"); - return borrowed; -} - -describe("plugin surface host runtime", () => { - it("creates a PR worktree and agent through usePaseo on the selected app host", async () => { - const selected = clientWithWorkspace("workspace-a"); - const runtime = createPluginSurfaceRuntime(selected.client, { - id: "workspace-plugin", - lifetime: new AbortController(), - }); - if (!runtime) throw new Error("Expected selected host runtime"); - - const paseo = borrowFromAppProvider(runtime.paseo); - const workspace = await paseo.workspaces.create({ - source: { - kind: "worktree", - cwd: "/tmp/repository", - action: "checkout", - checkoutSource: { kind: "change_request", forge: "github", number: 42 }, - }, - }); - const agent = await workspace.agents.create({ - config: { provider: "codex/gpt-5" }, - prompt: "Review PR #42", - }); - - expect(workspace.id).toBe("workspace-a"); - expect(agent.id).toBe("agent-workspace-a"); - expect(selected.createWorkspace).toHaveBeenCalledOnce(); - expect(selected.createAgent).toHaveBeenCalledOnce(); - }); - - it("switches all plugin calls when the selected host changes", async () => { - const hostA = clientWithWorkspace("workspace-a"); - const hostB = clientWithWorkspace("workspace-b"); - const first = createPluginSurfaceRuntime(hostA.client, { - id: "same-plugin", - lifetime: new AbortController(), - }); - const second = createPluginSurfaceRuntime(hostB.client, { - id: "same-plugin", - lifetime: new AbortController(), - }); - if (!first || !second) throw new Error("Expected online host runtimes"); - - await first.invoke("host", {}); - await borrowFromAppProvider(second.paseo).workspaces.create({ - source: { kind: "directory", path: "/tmp/workspace-b" }, - }); - - expect(hostA.invokePluginRpc).toHaveBeenCalledWith("same-plugin", "host", {}); - expect(hostA.createWorkspace).not.toHaveBeenCalled(); - expect(hostB.createWorkspace).toHaveBeenCalledOnce(); - }); - - it("keeps an offline selected host unavailable instead of borrowing another host", () => { - const otherHost = clientWithWorkspace("workspace-online"); - - expect( - createPluginSurfaceRuntime(null, { id: "same-plugin", lifetime: new AbortController() }), - ).toBeNull(); - expect(otherHost.createWorkspace).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts deleted file mode 100644 index 3c99e38aba..0000000000 --- a/packages/app/src/plugins/surface-runtime.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useState } from "react"; -import type { InstalledPlugin } from "./types"; -import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; - -export interface PluginSurfaceRuntime { - paseo: PaseoApi; - invoke(method: string, input: unknown): Promise; -} - -export function createPluginSurfaceRuntime( - client: DaemonClient | null, - plugin: Pick, -): PluginSurfaceRuntime | null { - if (!client || plugin.lifetime.signal.aborted) return null; - return { - paseo: createPaseoApi(client, { signal: plugin.lifetime.signal }), - invoke: (method, input) => client.invokePluginRpc(plugin.id, method, input), - }; -} - -/** A mounted surface owns its API; creating a React element creates no server demand. */ -export function usePluginSurfaceRuntime( - client: DaemonClient | null, - plugin: InstalledPlugin | null | undefined, -): PluginSurfaceRuntime | null { - const [mounted, setMounted] = useState<{ - client: DaemonClient; - plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; - } | null>(null); - useEffect(() => { - if (!client || !plugin) return; - const runtime = createPluginSurfaceRuntime(client, plugin); - if (!runtime) return; - setMounted({ client, plugin, runtime }); - return () => { - void runtime.paseo - .dispose() - .catch((error) => console.warn(`[Plugins] Surface cleanup failed for ${plugin.id}`, error)); - }; - }, [client, plugin]); - return mounted?.client === client && mounted.plugin === plugin ? mounted.runtime : null; -} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index c827cf158b..f2f9997749 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,15 +1,15 @@ import { router, useLocalSearchParams } from "expo-router"; -import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; -import { ChevronDown, X } from "lucide-react-native"; -import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; -import { Platform, Pressable, Text, View } from "react-native"; +import { X } from "lucide-react-native"; +import { useCallback, useMemo, type ComponentType } from "react"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostPicker } from "@/components/hosts/host-picker"; +import { HostFilter } from "@/components/hosts/host-filter"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; @@ -18,16 +18,21 @@ import { usePluginHostNavigation } from "./host-navigation"; import { resolvePluginIcon } from "./icons"; import { toPluginTheme } from "./theme"; import { useInstalledPlugin, usePluginInstallations } from "./registry"; -import { buildPluginSurfaceRoute } from "./routes"; -import { rememberPluginContributionHost } from "./contribution-host"; +import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes"; +import { + legacySidebarItemHostKey, + pluginScreensHostKey, + rememberPluginContributionHost, +} from "./contribution-host"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { PluginRuntimeBoundary } from "./runtime-boundary"; +import { PluginInstallationProvider } from "./installation-provider"; import { getPluginSurfaceContributionServerIds, + resolvePluginScreenTitle, resolvePluginSurfaceContribution, type PluginSurfaceContributionIdentity, } from "./surface-contribution"; +import { resolvePluginPlatform } from "./platform"; const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -35,7 +40,6 @@ const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme), }); const ThemedX = withUnistyles(X); -const ThemedChevronDown = withUnistyles(ChevronDown); function routeParam(value: string | string[] | undefined): string { return typeof value === "string" ? value : ""; @@ -55,44 +59,40 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - client, plugin, layout, host, + params, theme, }: { - Surface: ComponentType; - client: DaemonClient; + Surface: ComponentType; plugin: NonNullable>; - layout: PluginSurfaceProps["layout"]; - host: PluginSurfaceProps["host"]; + layout: PluginScreenProps["layout"]; + host: PluginScreenProps["host"]; + params: PluginScreenParams; theme: PluginTheme; }) { const navigation = usePluginHostNavigation(host.id); return ( - - - + + + ); } const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); -function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - -function PluginHostSwitcher({ +function PluginHostFilter({ serverId, pluginId, identity, + params, serverIds, }: { serverId: string; pluginId: string; identity: PluginSurfaceContributionIdentity; + params: PluginScreenParams; serverIds: string[]; }) { const allHosts = useHosts(); @@ -100,60 +100,47 @@ function PluginHostSwitcher({ () => allHosts.filter((host) => serverIds.includes(host.serverId)), [allHosts, serverIds], ); - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const selectHost = useCallback( (nextServerId: string) => { - rememberPluginContributionHost(`${pluginId}/${identity.kind}/${identity.id}`, nextServerId); - router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity)); + // A legacy row remembers its own host; a screen's host carries to all the plugin's items. + rememberPluginContributionHost( + identity.kind === "sidebar" + ? legacySidebarItemHostKey(pluginId, identity.id) + : pluginScreensHostKey(pluginId), + nextServerId, + ); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity, params)); }, - [identity, pluginId], + [identity, params, pluginId], ); - const openPicker = useCallback(() => setOpen(true), []); const show = serverIds.length > 1 && hosts.length > 1; if (!show) return null; return ( - - - - - {selectedLabel} - - - - - + selectedHost={serverId} + onSelectHost={selectHost} + includeAllHost={false} + triggerTestID="plugin-host-filter-trigger" + /> ); } export function PluginSurfaceScreen() { - const params = useLocalSearchParams<{ + const routeParams = useLocalSearchParams<{ serverId?: string | string[]; pluginId?: string | string[]; contributionKind?: string | string[]; contributionId?: string | string[]; }>(); - const serverId = routeParam(params.serverId); - const pluginId = routeParam(params.pluginId); - const contributionKind = routeParam(params.contributionKind); - const contributionId = routeParam(params.contributionId); + const serverId = routeParam(routeParams.serverId); + const pluginId = routeParam(routeParams.pluginId); + const contributionKind = routeParam(routeParams.contributionKind); + const contributionId = routeParam(routeParams.contributionId); + const paramsKey = JSON.stringify(pluginScreenParamsFromRoute(routeParams)); + // Keyed by content: the route hands back a new object on every render. + const params = useMemo(() => JSON.parse(paramsKey), [paramsKey]); const identity = useMemo(() => { if (contributionKind !== "sidebar" && contributionKind !== "surface") return null; return { kind: contributionKind, id: contributionId }; @@ -173,13 +160,19 @@ export function PluginSurfaceScreen() { identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], [identity, installations, pluginId], ); - const title = sidebarItem?.title ?? surface?.id ?? (pluginId || "Plugin"); + const title = useMemo( + () => + surface + ? resolvePluginScreenTitle(surface, sidebarItem, params) + : (sidebarItem?.title ?? (pluginId || "Plugin")), + [params, pluginId, sidebarItem, surface], + ); const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; const close = useCallback(() => { if (router.canGoBack()) router.back(); else router.replace(`/h/${encodeURIComponent(serverId)}`); }, [serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); const headerLeft = useMemo( () => ( @@ -189,7 +182,7 @@ export function PluginSurfaceScreen() { ) : null} - {title} + {title} ), [Icon, title], @@ -198,10 +191,11 @@ export function PluginSurfaceScreen() { () => ( <> {identity ? ( - ) : null} @@ -217,7 +211,7 @@ export function PluginSurfaceScreen() { ), - [close, contributionServerIds, identity, pluginId, serverId], + [close, contributionServerIds, identity, params, pluginId, serverId], ); return ( @@ -232,10 +226,10 @@ export function PluginSurfaceScreen() { > @@ -261,19 +255,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.statusDanger, padding: theme.spacing[4], }, - hostSwitcher: { - maxWidth: 180, - minHeight: 32, - paddingHorizontal: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface1, - }, - hostSwitcherText: { - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })); diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index d7c7537ade..c1548d0521 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -51,9 +51,12 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install clientBundle: serverId, lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 68e1378199..c64e498333 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -13,10 +13,13 @@ function plugin(input: { clientBundle: "bundle", lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => {}, settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 59d7c3ff36..d915216105 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -13,7 +13,8 @@ vi.mock("@/runtime/host-runtime", () => ({ useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); -vi.mock("@/constants/layout", () => ({ +vi.mock("@/constants/layout", async (importOriginal) => ({ + ...(await importOriginal()), useIsCompactFormFactor: () => false, })); vi.mock("../navigation", () => ({ @@ -38,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) { @@ -121,6 +124,7 @@ describe("PluginTimelineItemView", () => { [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], { client: daemonClient, + audio, }, ); @@ -150,6 +154,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -187,6 +192,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -210,7 +216,7 @@ describe("PluginTimelineItemView", () => { }); }); -it("releases a crashed renderer's observations and recovers a fresh scope in StrictMode", async () => { +it("a renderer releases its observation on the plugin's client when it crashes, and remounts in StrictMode", async () => { let receive: Parameters[0] = () => {}; let opened: () => void = () => {}; let nextId = 0; @@ -285,11 +291,9 @@ it("releases a crashed renderer's observations and recovers a fresh scope in Str return { default(plugin) { function Card(props) { const paseo = usePaseo(); - const failed = React.useRef(false); - failed.current = props.item.data.label === "explode"; React.useEffect(() => { const owner = paseo.observeEvents(["project.update"]); - return () => { if (failed.current) throw new Error("plugin cleanup failed"); void owner.release(); }; + return () => { void owner.release(); }; }, [paseo]); if (props.item.data.label === "explode") throw new Error("owned renderer failed"); return React.createElement("span", null, props.item.data.label); @@ -311,7 +315,7 @@ it("releases a crashed renderer's observations and recovers a fresh scope in Str clientBundle: liveBundle, }, ], - { client }, + { client, audio }, ); const container = document.createElement("div"); containers.push(container); diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 1490d8e14e..90f6e0e1a2 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -1,8 +1,8 @@ import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; -import type { PluginHostProps, PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import React, { type ComponentType, useMemo } from "react"; -import { Platform, Text } from "react-native"; +import { Text } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; @@ -10,18 +10,13 @@ import type { Theme } from "@/styles/theme"; import type { PluginTimelineStreamItem } from "@/types/stream"; import { createPluginClientStateSource } from "../client-state/source"; import { useInstalledPlugin } from "../registry"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; +import { resolvePluginPlatform } from "../platform"; const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); -function resolvePlatform(): PluginHostProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - function TimelineItemUnavailable() { return Plugin timeline item unavailable.; } @@ -63,7 +58,7 @@ function PluginTimelineItemBody({ const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); if (!plugin || !renderer || !parsed || !client) { @@ -86,11 +81,11 @@ function PluginTimelineItemBody({ }; return ( - + - + ); } diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9305cc3ba8..bab295d623 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -1,3 +1,4 @@ +import type { PaseoApi } from "@getpaseo/client"; import type { QueryClient } from "@tanstack/react-query"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { @@ -10,7 +11,8 @@ import type { PluginClientSlashCommandContribution, PluginComposerPillContribution, PluginSidebarContribution, - PluginSurfaceContribution, + PluginSidebarItemContribution, + PluginScreenContribution, PluginSettingsScreenContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, @@ -18,6 +20,8 @@ import type { PluginWorkspacePanelContribution, } from "@getpaseo/plugin/client"; +export type PluginSidebarSection = "header" | "footer"; + export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { locations: readonly PluginPanelLocation[]; }; @@ -25,9 +29,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont export interface EvaluatedPlugin { id: string; cleanup: PluginCleanup; - surfaces: PluginSurfaceContribution[]; + surfaces: PluginScreenContribution[]; settingsScreens: PluginSettingsScreenContribution[]; - sidebarItems: PluginSidebarContribution[]; + sidebarItems: Record; + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ + legacySidebarItems: PluginSidebarContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; @@ -39,6 +46,10 @@ export interface EvaluatedPlugin { export interface InstalledPlugin extends EvaluatedPlugin { lifetime: AbortController; + /** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */ + paseo: PaseoApi; + /** Calls one of the plugin's server RPC methods on its host. */ + invoke(method: string, input: unknown): Promise; serverId: string; requirements?: PluginRequirements; clientBundle: string; @@ -51,7 +62,8 @@ export type { PluginClientSlashCommandContribution, PluginComposerPillContribution, PluginSidebarContribution, - PluginSurfaceContribution, + PluginSidebarItemContribution, + PluginScreenContribution, PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, 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/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 78a8bb462a..998f7e90ff 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,13 +1,9 @@ -import type { - PluginAgentPanelProps, - PluginHostProps, - PluginWorkspacePanelProps, -} from "@getpaseo/plugin/client"; +import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; import { useMemo } from "react"; -import { Platform, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -23,20 +19,15 @@ import { createPluginClientStateSource } from "../client-state/source"; import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; +import { resolvePluginPlatform } from "../platform"; const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme), }); -function resolvePlatform(): PluginHostProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - function PluginPanelBody({ theme }: { theme: PluginTheme }) { const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); @@ -57,7 +48,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); const navigation = usePluginHostNavigation(serverId); @@ -105,9 +96,9 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { Surface={Surface} key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`} > - + {panel} - + ); } diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index c4ad2ef302..a7e46de422 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -10,10 +10,13 @@ function installed(): InstalledPlugin { clientBundle: "bundle", lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => {}, settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/runtime/host-confirmation.ts b/packages/app/src/runtime/host-confirmation.ts new file mode 100644 index 0000000000..8fac1031ff --- /dev/null +++ b/packages/app/src/runtime/host-confirmation.ts @@ -0,0 +1,97 @@ +import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import { + hostHasConnection, + relayConnectionFromOffer, + type HostProfile, +} from "@/types/host-connection"; + +/** + * The question shown before a pairing link adds a host that is not saved, or + * changes the key, relay, or TLS setting of a saved one. + */ +export interface HostConfirmationRequest { + /** Identifies this question, so an answer shown for it never answers a newer one. */ + id: number; + serverId: string; + /** Grouped daemon public key the user can compare with the one their daemon shows. */ + keyFingerprint: string; + relayEndpoint: string; + kind: "newHost" | "changedConnection"; +} + +interface PendingConfirmation { + request: HostConfirmationRequest; + resolve: (approved: boolean) => void; +} + +/** + * Owns the one host confirmation the app is waiting on. The host runtime asks + * through `confirmLink`; the view reads `getPending` and replies with `answer`. + * A question waits until it is answered, whether or not a view is showing it. + */ +export class HostConfirmations { + private pending: PendingConfirmation | null = null; + private nextRequestId = 1; + private listeners = new Set<() => void>(); + + getPending(): HostConfirmationRequest | null { + return this.pending?.request ?? null; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Answers the request with `requestId`; an answer for any other request is ignored. */ + answer(requestId: number, approved: boolean): void { + const current = this.pending; + if (!current || current.request.id !== requestId) return; + this.pending = null; + this.emit(); + current.resolve(approved); + } + + /** Resolves true without asking when the link matches a saved host exactly. */ + confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise { + const connection = relayConnectionFromOffer(offer); + const savedHost = savedHosts.find((host) => host.serverId === offer.serverId); + if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true); + return this.ask({ + id: this.nextRequestId++, + serverId: offer.serverId, + keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), + relayEndpoint: connection.relayEndpoint, + kind: savedHost ? "changedConnection" : "newHost", + }); + } + + private ask(request: HostConfirmationRequest): Promise { + // One question at a time: a newer one cancels the unanswered one. + this.pending?.resolve(false); + return new Promise((resolve) => { + this.pending = { request, resolve }; + this.emit(); + }); + } + + private emit(): void { + for (const listener of this.listeners) { + listener(); + } + } +} + +/** + * Shows the key material itself (not a re-hash) in groups of four. Keys longer + * than 16 characters show only their first and last 8, so two keys that share + * both ends show the same value. + */ +function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string { + const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, ""); + const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim(); + if (normalized.length <= 16) return group(normalized); + return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`; +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 280eae793e..a3ea10f48b 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -31,6 +31,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; import { subscriptionFixture } from "./subscription-fixture"; import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential"; +import type { HostConfirmationRequest } from "./host-confirmation"; it("requests the managed connection credential through desktop main without a web hint", async () => { const requests: string[] = []; @@ -507,6 +508,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient { return client; } +/** + * Answers every host confirmation the store asks, the way the sheet does, and + * records each request. + */ +function answerHostConfirmations( + store: HostRuntimeStore, + answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean), +): HostConfirmationRequest[] { + const requests: HostConfirmationRequest[] = []; + store.subscribeHostConfirmation(() => { + const pending = store.getPendingHostConfirmation(); + if (!pending) return; + requests.push(pending); + store.answerHostConfirmation( + pending.id, + typeof answer === "function" ? answer(pending, requests.length - 1) : answer, + ); + }); + return requests; +} + +function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest { + const pending = store.getPendingHostConfirmation(); + if (!pending) throw new Error("Expected a pending host confirmation"); + return pending; +} + +function createPairingStore( + input: { + hostname?: string; + password?: string; + connectAttempts?: string[]; + serverIdForHost?: (host: HostProfile) => string; + storage?: HostRuntimeStorage; + } = {}, +): HostRuntimeStore { + return new HostRuntimeStore({ + storage: input.storage ?? createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host, connection }) => { + input.connectAttempts?.push(connection.id); + if (input.password && host.password !== input.password) { + throw new DaemonAuthenticationError("password_required"); + } + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: input.serverIdForHost?.(host) ?? host.serverId, + hostname: input.hostname ?? "paired", + }; + }, + getClientId: async () => "cid_pairing", + readInitialConnectionHint: () => null, + }, + }); +} + function createMemoryHostRuntimeStorage(entries: Record = {}): HostRuntimeStorage { const values = new Map(Object.entries(entries)); return { @@ -3547,19 +3605,10 @@ describe("HostRuntimeStore", () => { }); it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore({ hostname: "mbp" }); + answerHostConfirmations(store, true); - await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.label).toBe("mbp"); @@ -3568,27 +3617,14 @@ describe("HostRuntimeStore", () => { }); it("stores relay TLS from a pairing offer", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore(); + answerHostConfirmations(store, true); - await store.upsertConnectionFromOffer( - makeOffer({ - relay: { - endpoint: "relay.example.com:443", - useTls: true, - }, - }), - "tls relay", - ); + await store + .beginLinkPairing() + .submit( + encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })), + ); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.connections).toEqual([ @@ -3605,17 +3641,8 @@ describe("HostRuntimeStore", () => { }); it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore(); + answerHostConfirmations(store, true); const oldPairingUrl = encodeOfferUrl({ v: 2, serverId: "srv_offer", @@ -3623,7 +3650,7 @@ describe("HostRuntimeStore", () => { relay: { endpoint: "relay.paseo.sh:443" }, }); - await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay"); + await store.importConnectionLink(oldPairingUrl, "openProject"); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.connections).toEqual([ @@ -3640,54 +3667,266 @@ describe("HostRuntimeStore", () => { }); it("probes a pairing link immediately and saves only after admission", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => { - if (host.password !== "correct-password") - throw new DaemonAuthenticationError("password_required"); - return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: "paired host", - }; - }, - getClientId: async () => "cid_pairing", - }, - }); + const store = createPairingStore({ password: "correct-password" }); + answerHostConfirmations(store, true); const offerUrl = encodeOfferUrl(makeOffer()); - await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow( - "Password required", - ); + const pairing = store.beginLinkPairing(); + + await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required"); expect(store.getHosts()).toHaveLength(0); - const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password"); - expect(result.serverId).toBe("srv_offer"); + const result = await pairing.submit(offerUrl, "correct-password"); + expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" }); expect(store.getHosts()[0]?.password).toBe("correct-password"); store.syncHosts([]); }); - it("preserves the existing host label when re-pairing an existing relay host", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", + it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => { + const connectAttempts: string[] = []; + const store = createPairingStore({ connectAttempts }); + const requests = answerHostConfirmations(store, false); + const offerUrl = encodeOfferUrl(makeOffer()); + const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true"; + + // `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals. + await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({ + status: "cancelled", + }); + + expect(requests).toEqual([ + ...Array.from({ length: 3 }, () => ({ + id: expect.any(Number), + serverId: "srv_offer", + keyFingerprint: "pkte stof fer", + relayEndpoint: "relay.paseo.sh:443", + kind: "newHost", + })), + { + id: expect.any(Number), + serverId: "srv_relay", + keyFingerprint: "AAAA", + relayEndpoint: "relay.example:443", + kind: "newHost", }, - storage: createMemoryHostRuntimeStorage(), - }); + ]); + expect(store.getHosts()).toEqual([]); + expect(connectAttempts).toEqual([]); + store.syncHosts([]); + }); - await store.upsertRelayConnection({ + it("connects a saved host from a matching pairing link without asking", async () => { + const store = createPairingStore(); + const requests = answerHostConfirmations(store, true); + const offerUrl = encodeOfferUrl(makeOffer()); + await store.beginLinkPairing().submit(offerUrl); + expect(requests).toHaveLength(1); + + await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({ + status: "connected", serverId: "srv_offer", - relayEndpoint: "relay.paseo.sh:443", - daemonPublicKeyB64: "pk_test_offer", - label: "Custom name", + }); + await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({ + status: "connected", + serverId: "srv_offer", + }); + await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({ + status: "connected", }); - await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + expect(requests).toHaveLength(1); + expect(store.getHosts()).toHaveLength(1); + store.syncHosts([]); + }); + + it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => { + const store = createPairingStore(); + const requests = answerHostConfirmations(store, (_request, index) => index === 0); + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + const savedHosts = store.getHosts(); + + const changedLinks = [ + makeOffer({ daemonPublicKeyB64: "pk_other_key" }), + makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }), + makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }), + ].map(encodeOfferUrl); + for (const link of changedLinks) { + await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({ + status: "cancelled", + }); + } + + expect(requests.map((request) => request.kind)).toEqual([ + "newHost", + ...Array.from({ length: 6 }, () => "changedConnection"), + ]); + expect(store.getHosts()).toEqual(savedHosts); + store.syncHosts([]); + }); + + it("asks once per pairing flow and link when the host asks for a password", async () => { + const store = createPairingStore({ password: "correct-password" }); + const requests = answerHostConfirmations(store, (_request, index) => index < 2); + const offerUrl = encodeOfferUrl(makeOffer()); + const pairing = store.beginLinkPairing(); + + await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required"); + await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow(); + expect(requests).toHaveLength(1); + + // A new pairing flow for the same link asks again. + await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow(); + expect(requests).toHaveLength(2); + + await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({ + status: "connected", + }); + expect(requests).toHaveLength(2); + expect(store.getHosts()[0]).toMatchObject({ + serverId: "srv_offer", + password: "correct-password", + }); + + // A different link in the same flow asks again. + const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" })); + await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({ + status: "cancelled", + }); + expect(requests).toHaveLength(3); + store.syncHosts([]); + }); + + it("continues a scanned link's password retry without asking again", async () => { + const store = createPairingStore({ password: "correct-password" }); + const requests = answerHostConfirmations(store, true); + const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true"; + + const outcome = await store.importConnectionLink(link, "hostRoot"); + expect(outcome).toMatchObject({ status: "password_required", link }); + expect(store.getHosts()).toHaveLength(0); + if (outcome.status !== "password_required") throw new Error("expected password_required"); + + await outcome.pairing.submit(outcome.link, "correct-password"); + expect(requests).toHaveLength(1); + expect(store.getHosts()[0]).toMatchObject({ + serverId: "srv_pair", + password: "correct-password", + }); + store.syncHosts([]); + }); + + it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => { + const store = createPairingStore(); + const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }), + ); + + const second = store.importConnectionLink( + encodeOfferUrl(makeOffer({ serverId: "srv_second" })), + "openProject", + ); + await expect(first).resolves.toEqual({ status: "cancelled" }); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }), + ); + + store.answerHostConfirmation(requirePendingConfirmation(store).id, true); + await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" }); + expect(store.getPendingHostConfirmation()).toBeNull(); + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]); + store.syncHosts([]); + }); + + it("ignores an answer for a host confirmation that a newer one replaced", async () => { + const store = createPairingStore(); + const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }), + ); + const firstRequest = requirePendingConfirmation(store); + const second = store.importConnectionLink( + encodeOfferUrl(makeOffer({ serverId: "srv_second" })), + "openProject", + ); + await expect(first).resolves.toEqual({ status: "cancelled" }); + + // Connect tapped on the first sheet as the second link arrives. + store.answerHostConfirmation(firstRequest.id, true); + + const secondRequest = requirePendingConfirmation(store); + expect(secondRequest.serverId).toBe("srv_second"); + store.answerHostConfirmation(secondRequest.id, false); + await expect(second).resolves.toEqual({ status: "cancelled" }); + expect(store.getHosts()).toEqual([]); + }); + + it("waits for saved hosts to load before deciding whether a pairing link asks", async () => { + const savedHost = makeHost({ + serverId: "srv_offer", + connections: [ + { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: false, + daemonPublicKeyB64: "pk_test_offer", + }, + ], + }); + const store = createPairingStore({ + storage: createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([savedHost]), + "@paseo:e2e": "1", + }), + }); + const requests = answerHostConfirmations(store, true); + + await expect( + store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"), + ).resolves.toEqual({ status: "connected", serverId: "srv_offer" }); + + expect(requests).toEqual([]); + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]); + store.syncHosts([]); + }); + + it("adds hosts by address and the default localhost without asking", async () => { + const store = createPairingStore({ + serverIdForHost: (host) => + host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "", + }); + const requests = answerHostConfirmations(store, false); + + await store.boot(); + await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" }); + + expect(requests).toEqual([]); + expect(store.getHosts().map((host) => host.serverId)).toEqual([ + "srv_localhost:6767", + "srv_10.0.0.5:6767", + ]); + store.syncHosts([]); + }); + + it("preserves the existing host label when re-pairing an existing relay host", async () => { + const store = createPairingStore({ hostname: "mbp" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await store.renameHost("srv_offer", "Custom name"); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.label).toBe("Custom name"); @@ -3963,35 +4202,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => { expect(probeAttempts).toBe(attemptsBeforeRetry); store.syncHosts([]); }); - it("imports a pairing link after a password retry in the add flow", async () => { - const store = new HostRuntimeStore({ - storage: createMemoryHostRuntimeStorage(), - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => { - if (host.password !== "correct-password") - throw new DaemonAuthenticationError("password_required"); - return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: "paired", - }; - }, - getClientId: async () => "cid_link_import", - }, - }); - const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true"; - await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({ - status: "password_required", - }); - expect(store.getHosts()).toHaveLength(0); - await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password"); - expect(store.getHosts()[0]).toMatchObject({ - serverId: "srv_pair", - password: "correct-password", - }); - store.syncHosts([]); - }); it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => { const store = new HostRuntimeStore({ storage: createMemoryHostRuntimeStorage(), @@ -4003,6 +4213,7 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => { getClientId: async () => "cid_deep_link", }, }); + answerHostConfirmations(store, true); const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true"; await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({ status: "connected", @@ -4017,6 +4228,38 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => { expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error"); store.syncHosts([]); }); + it("stops connecting to a host removed while its first probe is pending", async () => { + useHostRuntimeClock(); + const firstProbe = createDeferred(); + let connectCalls = 0; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => { + connectCalls += 1; + if (connectCalls === 1) await firstProbe.promise; + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_removed_host", + }, + }); + await store.upsertDirectConnection({ serverId: "srv_removed", endpoint: "lan:6767" }); + await vi.waitFor(() => expect(connectCalls).toBe(1)); + + await store.removeHost("srv_removed"); + firstProbe.resolve(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(connectCalls).toBe(1); + expect(store.getSnapshot("srv_removed")).toBeNull(); + expect(useSessionStore.getState().sessions["srv_removed"]).toBeUndefined(); + }); + it("saves and reconnects a rejected saved host after changing its password", async () => { const store = new HostRuntimeStore({ storage: createMemoryHostRuntimeStorage(), diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 7328e7e5b4..1e0fdb7cad 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -14,6 +14,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + relayConnectionFromOffer, StoredHostRegistrySchema, type HostConnection, type HostProfile, @@ -29,6 +30,7 @@ import { } from "@/utils/daemon-endpoints"; import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; @@ -89,6 +91,27 @@ export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "of export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings"; export type HostRegistryStatus = "loading" | "ready"; +/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */ +export interface LinkPairing { + /** Probes the linked host and saves it. Connection and password errors throw. */ + submit(link: string, password?: string): Promise; +} + +export type LinkPairingResult = + | { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null } + | { status: "cancelled" }; + +/** A pairing link the user confirmed whose host then asked for a password. */ +export interface PasswordRequiredPairing { + link: string; + pairing: LinkPairing; +} + +export type ConnectionLinkImport = + | { status: "connected"; serverId: string } + | ({ status: "password_required" } & PasswordRequiredPairing) + | { status: "cancelled" }; + export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } @@ -677,8 +700,15 @@ export class HostRuntimeController { connectionId: options.initialConnection.connectionId, existingClient: options.initialConnection.existingClient, }); + if (!this.started) { + return; + } } await this.runProbeCycleNow(); + // stop() can run while startup is pending; a stopped controller never probes again. + if (!this.started) { + return; + } if (options?.autoProbe !== false) { this.probeIntervalHandle = setInterval(() => { void this.runProbeCycleNow(); @@ -1408,6 +1438,13 @@ function readConfiguredLocalDaemonOverride(): string | null { return value && value.length > 0 ? value : null; } +function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean { + return ( + left.serverId === right.serverId && + equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right)) + ); +} + function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } { if (input.trim().startsWith("relay://") || input.includes("#connect=")) { return parseRelayConnectionUri(input); @@ -1469,6 +1506,8 @@ export class HostRuntimeStore { private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; + private registryLoad: Promise | null = null; + private readonly hostConfirmations = new HostConfirmations(); private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -1519,8 +1558,7 @@ export class HostRuntimeStore { private async runBoot(): Promise { const override = readConfiguredLocalDaemonOverride(); - await this.loadFromStorage(); - this.markHostRegistryLoaded(); + await this.loadRegistry(); let isE2E: string | null = null; try { @@ -1553,6 +1591,13 @@ export class HostRuntimeStore { } } + private loadRegistry(): Promise { + if (!this.registryLoad) { + this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded()); + } + return this.registryLoad; + } + private async loadFromStorage(): Promise { let shouldPersistHosts = false; let profiles: HostProfile[] = []; @@ -1879,7 +1924,7 @@ export class HostRuntimeStore { }); } - async upsertRelayConnection(input: { + private async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; useTls?: boolean; @@ -1912,51 +1957,91 @@ export class HostRuntimeStore { return profile; } - async upsertConnectionFromOffer( + // A pairing link (a URL, a QR code, or a pasted link) asks the user to + // confirm when it adds a new host or changes a saved host's key, relay, or + // TLS setting. The answer needs the saved hosts, so it waits for them to load. + private async confirmLink(offer: ConnectionOffer): Promise { + await this.loadRegistry(); + return this.hostConfirmations.confirmLink(offer, this.hosts); + } + + getPendingHostConfirmation(): HostConfirmationRequest | null { + return this.hostConfirmations.getPending(); + } + + subscribeHostConfirmation(listener: () => void): () => void { + return this.hostConfirmations.subscribe(listener); + } + + answerHostConfirmation(requestId: number, approved: boolean): void { + this.hostConfirmations.answer(requestId, approved); + } + + /** + * Starts one pairing flow, such as one open pairing modal. Once the user + * confirms a link, submitting the same link again in this flow (the + * password retry) does not ask again. A new flow asks again. + */ + beginLinkPairing(): LinkPairing { + let approvedOffer: ConnectionOffer | null = null; + return { + submit: async (link, password) => { + const { offer, password: linkPassword } = parseOfferConnectionUrl(link); + if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) { + if (!(await this.confirmLink(offer))) return { status: "cancelled" }; + approvedOffer = offer; + } + const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword); + return { status: "connected", ...result }; + }, + }; + } + + async importConnectionLink( + url: string, + target: PairingNavigationTarget, + ): Promise { + if (target === "openProject") { + const { offer, password } = parseOfferConnectionUrl(url); + if (!(await this.confirmLink(offer))) return { status: "cancelled" }; + const profile = await this.upsertConnectionFromOffer(offer, undefined, password); + return { status: "connected", serverId: profile.serverId }; + } + const pairing = this.beginLinkPairing(); + try { + const result = await pairing.submit(url); + if (result.status === "cancelled") return result; + return { status: "connected", serverId: result.serverId }; + } catch (error) { + if (!getConnectionAuthFailureReason(error)) throw error; + return { status: "password_required", link: url, pairing }; + } + } + + private async upsertConnectionFromOffer( offer: ConnectionOffer, label?: string, password?: string, ): Promise { - // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. - const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + const connection = relayConnectionFromOffer(offer); return this.upsertRelayConnection({ serverId: offer.serverId, - relayEndpoint: offer.relay.endpoint, - useTls, - daemonPublicKeyB64: offer.daemonPublicKeyB64, + relayEndpoint: connection.relayEndpoint, + useTls: connection.useTls, + daemonPublicKeyB64: connection.daemonPublicKeyB64, label, password, }); } - async upsertConnectionFromOfferUrl( - offerUrlOrFragment: string, - label?: string, - password?: string, - ): Promise { - const parsed = parseOfferConnectionUrl(offerUrlOrFragment); - return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password); - } - - async probeAndUpsertConnectionFromOfferUrl( - offerUrlOrFragment: string, - password?: string, + private async probeAndUpsertOffer( + offer: ConnectionOffer, + password: string | undefined, ): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { - const parsed = parseOfferConnectionUrl(offerUrlOrFragment); - const offer = parsed.offer; - const credential = password ?? parsed.password; - const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); - const relayEndpoint = normalizeHostPort(offer.relay.endpoint); - const connection: HostConnection = { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - useTls, - daemonPublicKeyB64: offer.daemonPublicKeyB64, - }; + const connection = relayConnectionFromOffer(offer); const probeHost: HostProfile = { serverId: offer.serverId, - ...(credential ? { password: credential } : {}), + ...(password ? { password } : {}), label: offer.serverId, appearance: defaultHostAppearance(), lifecycle: {}, @@ -1967,27 +2052,10 @@ export class HostRuntimeStore { }; const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection }); await client.close().catch(() => undefined); - const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential); + const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password); return { profile, serverId: offer.serverId, hostname }; } - async importConnectionLink( - url: string, - target: PairingNavigationTarget, - ): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> { - if (target === "openProject") { - const profile = await this.upsertConnectionFromOfferUrl(url); - return { status: "connected", serverId: profile.serverId }; - } - try { - const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url); - return { status: "connected", serverId }; - } catch (error) { - if (!getConnectionAuthFailureReason(error)) throw error; - return { status: "password_required" }; - } - } - async upsertConnectionFromListen(input: { listenAddress: string; serverId: string; @@ -2735,6 +2803,29 @@ export function useHostRegistryLoaded(): boolean { ); } +export function useHostConfirmation(): { + pending: HostConfirmationRequest | null; + answer: (approved: boolean) => void; +} { + const store = getHostRuntimeStore(); + const pending = useSyncExternalStore( + (onStoreChange) => store.subscribeHostConfirmation(onStoreChange), + () => store.getPendingHostConfirmation(), + () => store.getPendingHostConfirmation(), + ); + // The answer is bound to the request this render shows, so a tap never + // answers a request that replaced it. + return useMemo( + () => ({ + pending, + answer: (approved: boolean) => { + if (pending) store.answerHostConfirmation(pending.id, approved); + }, + }), + [pending, store], + ); +} + export interface HostMutations { upsertDirectConnection: (input: { serverId: string; @@ -2755,24 +2846,7 @@ export interface HostMutations { daemonPort?: number; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; - upsertRelayConnection: (input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }) => Promise; - upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise; - upsertConnectionFromOfferUrl: ( - offerUrlOrFragment: string, - label?: string, - password?: string, - ) => Promise; - probeAndUpsertConnectionFromOfferUrl: ( - offerUrlOrFragment: string, - password?: string, - ) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; + beginLinkPairing: () => LinkPairing; renameHost: (serverId: string, label: string) => Promise; setHostColor: (serverId: string, color: HostColor) => Promise; setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise; @@ -2787,12 +2861,7 @@ export function useHostMutations(): HostMutations { upsertDirectConnection: (input) => store.upsertDirectConnection(input), probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input), - upsertRelayConnection: (input) => store.upsertRelayConnection(input), - upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), - upsertConnectionFromOfferUrl: (url, label, password) => - store.upsertConnectionFromOfferUrl(url, label, password), - probeAndUpsertConnectionFromOfferUrl: (url, password) => - store.probeAndUpsertConnectionFromOfferUrl(url, password), + beginLinkPairing: () => store.beginLinkPairing(), renameHost: (serverId, label) => store.renameHost(serverId, label), setHostColor: (serverId, color) => store.setHostColor(serverId, color), setHostBadgeDisplay: (serverId, badgeDisplay) => diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 9e0d25144c..c919ab476d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -122,6 +122,8 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { ImportSessionButton } from "./new-workspace/import-session-button"; +import { useImportSession } from "@/hooks/use-import-session"; import { buildTerminalsQueryKey, type ListTerminalsPayload, @@ -2367,6 +2369,7 @@ export function NewWorkspaceScreen({ }); const screenHeaderLeft = useMemo(() => , []); + const importSession = useImportSession({ serverId: selectedServerId }); const composer = isTerminalLaunch ? ( {composer} {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } @@ -2448,15 +2453,20 @@ function NewWorkspaceLayout({ isCompact, title, formStack, + onImportSession, children, }: { isCompact: boolean; title: string; formStack: ReactNode; + onImportSession: () => void; children: ReactNode; }) { + // At the top of the screen on compact layouts, under the composer otherwise. + const importSessionButton = ; const setupFields = ( <> + {isCompact ? {importSessionButton} : null} {title} @@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({ return ( {setupFields} - {children} + <> + {children} + {isCompact ? null : importSessionButton} + ); } @@ -2481,6 +2494,12 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", flex: 1, }, + // Takes the free space above the setup fields, so its button sits at the top of the screen. + // The inset puts the ghost button's icon on the setup rows' icon rail. + compactTopActions: { + flex: 1, + paddingHorizontal: theme.spacing[3], + }, composerTitleContainer: { marginBottom: theme.spacing[8], paddingLeft: theme.spacing[6], @@ -2503,7 +2522,8 @@ const styles = StyleSheet.create((theme) => ({ formStackDesktop: { flexDirection: "row", alignItems: "center", - marginBottom: theme.spacing[8], + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], // The badge adds its own left padding; offset it so the project icon's left // edge lands exactly on the "New workspace" title's left edge. The trailing // inset mirrors it so the launch chip stops on the composer's inner content diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx new file mode 100644 index 0000000000..39b55e1b2e --- /dev/null +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -0,0 +1,50 @@ +import { Import } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; + +/** + * The new workspace screen's way into Import session: a ghost button at the top on compact + * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. + */ +export function ImportSessionButton({ + compact, + onPress, +}: { + compact: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compact: { + alignItems: "flex-start", + }, + // The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's + // border lines up with the composer's border. + wide: { + alignItems: "flex-start", + paddingHorizontal: theme.spacing[4], + }, + pill: { + borderRadius: theme.borderRadius.full, + borderColor: theme.colors.borderAccent, + }, +})); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index f7b27f820f..6b308547a1 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -63,7 +63,7 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons import { formatLatency } from "@/utils/latency"; import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { BrowserToolsOptInCard } from "./browser-tools-card"; import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; @@ -90,7 +90,7 @@ interface DynamicProviderIconProps { } function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { - const Icon = getProviderIcon(iconKey); + const Icon = useProviderIcon(iconKey); return ; } diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 8e8ef4a8a4..77aaa4d8ec 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -233,7 +233,7 @@ vi.mock("@/components/ui/dropdown-menu", () => ({ })); vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: (provider: string) => () => + useProviderIcon: (provider: string) => () => React.createElement("span", { "data-icon": `provider-${provider}` }), })); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 69e5caadac..4495acba83 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -22,7 +22,7 @@ import { type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { @@ -183,7 +183,7 @@ function ProviderRow({ const { t } = useTranslation(); const { theme } = useUnistyles(); const isCompact = useIsCompactFormFactor(); - const ProviderIcon = getProviderIcon(def.id, serverId); + const ProviderIcon = useProviderIcon(def.id, serverId); const providerError = enabled && entry.status === "error" && diff --git a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx index 90d6e02344..7952a2b1ee 100644 --- a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx @@ -5,7 +5,9 @@ import type { TFunction } from "i18next"; import { ArrowDown, ArrowUp, + Blocks, CalendarClock, + Gauge, History, Plus, Search, @@ -16,14 +18,15 @@ import { Button } from "@/components/ui/button"; import { Shortcut } from "@/components/ui/shortcut"; import { Switch } from "@/components/ui/switch"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { resolvePluginIcon } from "@/plugins/icons"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { builtinSidebarNavLabelKey, builtinSidebarNavShortcutAction, - type BuiltinSidebarNavId, + type BuiltinSidebarItemId, type SidebarNavItem, + type SidebarSection, } from "@/sidebar-nav/model"; +import { resolvePluginIcon } from "@/plugins/icons"; import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -36,13 +39,17 @@ const ThemedArrowDown = withUnistyles(ArrowDown); const moveUpIcon = ; const moveDownIcon = ; -const BUILTIN_ICONS: Record = { +const BUILTIN_ICONS: Record = { "new-workspace": Plus, history: History, search: Search, schedules: CalendarClock, + usage: Gauge, }; +/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */ +const PLUGIN_ICON = Blocks; + function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { return ; } @@ -50,7 +57,8 @@ function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { const ThemedNavIcon = withUnistyles(NavIcon); function navItemIcon(item: SidebarNavItem): LucideIcon { - return item.kind === "builtin" ? BUILTIN_ICONS[item.id] : resolvePluginIcon(item.group.icon); + if (item.kind === "builtin") return BUILTIN_ICONS[item.id]; + return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON; } function navItemLabel(t: TFunction, item: SidebarNavItem): string { @@ -134,15 +142,26 @@ function SidebarNavRow({ ); } -export function SidebarNavSection(): ReactElement { +const SECTION_COPY = { + header: { + title: "settings.appearance.sidebar.header.title", + info: "settings.appearance.sidebar.header.description", + }, + footer: { + title: "settings.appearance.sidebar.footer.title", + info: "settings.appearance.sidebar.footer.description", + }, +} as const satisfies Record; + +function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElement { const { t } = useTranslation(); - const { items, setVisible, move } = useSidebarNavItems(); + const { items, setVisible, move } = useSidebarNavItems(section); return ( {items.map((item, index) => ( @@ -160,6 +179,16 @@ export function SidebarNavSection(): ReactElement { ); } +/** Settings > Sidebar: one card per section. The footer's bottom line is fixed and not listed. */ +export function SidebarNavSection(): ReactElement { + return ( + <> + + + + ); +} + const styles = StyleSheet.create((theme) => ({ row: { gap: theme.spacing[2], 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 9e468ec34a..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; @@ -834,9 +857,8 @@ function TabChip({ {...(dragHandleProps?.listeners as object | undefined)} testID={`workspace-tab-${testIdentity}`} triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} - enabledOnMobile={false} style={tabChipStyle} - onPressIn={handleNavigateTab} + onPressIn={isWeb ? handleNavigateTab : undefined} onPress={handleNavigateTab} accessibilityRole="button" accessibilityLabel={accessibilityLabel} @@ -989,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { } function ResolvedWorkspaceDesktopTabsRow({ + host = "main", + launchPurpose = "primary", paneId, isFocused = false, + ownsKeyboardShortcuts = isFocused, tabs, normalizedServerId, normalizedWorkspaceId, @@ -1060,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"), @@ -1204,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({ @@ -1222,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ paneId, }), actions: ["workspace.tab.menu.open"], - enabled: isFocused, + enabled: ownsKeyboardShortcuts, priority: 200, handle: handleNewTabKeyboardAction, }); @@ -1234,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; @@ -1361,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 3ccf39da35..b2b226e645 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -9,7 +9,7 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - minTabWidth: 96, + minTabWidth: 64, maxTabWidth: 160, tabIconWidth: 14, tabContentGap: 4, @@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => { 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 fa1ef6763c..f7e7ba0933 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -12,11 +12,11 @@ import { function group(pluginId: string, contributionId: string): PluginSidebarGroup { return { + kind: "item", key: `${pluginId}/sidebar/${contributionId}`, pluginId, contributionId, title: contributionId, - icon: "puzzle", targets: [], }; } @@ -32,7 +32,11 @@ function summarize(items: readonly SidebarNavItem[]): SidebarNavPreference[] { describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban, notes], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban, notes], + preferences: [], + }); expect(summarize(items)).toEqual([ { key: "new-workspace", visible: true }, @@ -53,6 +57,7 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -73,6 +78,7 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -91,6 +97,7 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -107,9 +114,23 @@ describe("resolveSidebarNavItems", () => { }); }); +describe("resolveSidebarNavItems for the Usage item", () => { + it("hides the Usage item until it is turned on", () => { + const resolve = (preferences: SidebarNavPreference[]) => + summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences })); + + expect(resolve([])).toEqual([{ key: "usage", visible: false }]); + expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]); + }); +}); + describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "search", visible: false, previous: [] }); @@ -127,7 +148,11 @@ describe("setSidebarNavItemVisible", () => { { key: notesKey, visible: false }, { key: "history", visible: true }, ]; - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: previous, + }); const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous }); @@ -148,7 +173,11 @@ describe("setSidebarNavItemVisible", () => { { key: "search", visible: true }, { key: "schedules", visible: true }, ]; - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: previous, + }); const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous }); @@ -159,13 +188,23 @@ describe("setSidebarNavItemVisible", () => { { key: "search", visible: true }, { key: "schedules", visible: true }, ]); - expect(summarize(resolveSidebarNavItems({ pluginGroups: [notes], preferences: next }))).toEqual( - next, - ); + expect( + summarize( + resolveSidebarNavItems({ + section: "header", + pluginGroups: [notes], + preferences: next, + }), + ), + ).toEqual(next); }); it("returns the normalized list unchanged for an unknown key", () => { - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] }); @@ -174,7 +213,11 @@ describe("setSidebarNavItemVisible", () => { }); describe("moveSidebarNavItem", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban], + preferences: [], + }); it("moves an item up", () => { const next = moveSidebarNavItem({ items, key: "search", direction: "up", previous: [] }); @@ -241,3 +284,79 @@ describe("builtinSidebarNavShortcutAction", () => { expect(builtinSidebarNavShortcutAction("schedules")).toBeNull(); }); }); + +describe("footer section", () => { + const sync = group("sync", "status"); + const syncKey = pluginSidebarNavKey(sync); + + it("resolves the Usage item first, then plugin rows", () => { + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: [], + }); + + expect(summarize(items)).toEqual([ + { key: "usage", visible: false }, + { key: syncKey, visible: true }, + ]); + }); + + it("ignores header built-ins stored in footer preferences", () => { + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [], + preferences: [{ key: "history", visible: false }], + }); + + 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", + pluginGroups: [sync], + preferences: [ + { key: "add-project", visible: false }, + { key: "hosts", visible: false }, + { key: "import", visible: false }, + { key: "help", visible: false }, + { key: syncKey, visible: true }, + { key: "usage", visible: false }, + ], + }); + + expect(summarize(items)).toEqual([ + { key: syncKey, visible: true }, + { key: "usage", visible: false }, + ]); + }); + + 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", + pluginGroups: [sync], + preferences: previous, + }); + + const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous }); + const shown = setSidebarNavItemVisible({ + items: resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: moved, + }), + key: "usage", + visible: true, + previous: moved, + }); + + expect(shown).toEqual([ + notesPreference, + { key: syncKey, visible: true }, + { key: "usage", visible: true }, + ]); + }); +}); diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index f3dbd33520..6b98821e93 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -1,7 +1,20 @@ import type { PluginSidebarGroup } from "@/plugins/sidebar-groups"; +import type { PluginSidebarSection } from "@/plugins/types"; -export const BUILTIN_SIDEBAR_NAV_IDS = ["new-workspace", "history", "search", "schedules"] as const; -export type BuiltinSidebarNavId = (typeof BUILTIN_SIDEBAR_NAV_IDS)[number]; +export type SidebarSection = PluginSidebarSection; + +/** + * Each section's built-in items in their default order. The footer's bottom line + * (Add project and the Hosts, Help and support, Settings icons) is fixed. + */ +export const BUILTIN_SIDEBAR_ITEM_IDS = { + header: ["new-workspace", "history", "search", "schedules"], + footer: ["usage"], +} as const satisfies Record; + +export type BuiltinSidebarItemId
= + (typeof BUILTIN_SIDEBAR_ITEM_IDS)[Section][number]; +export type BuiltinSidebarNavId = BuiltinSidebarItemId<"header">; /** Persisted shape. Array order is the display order. */ export interface SidebarNavPreference { @@ -9,10 +22,10 @@ export interface SidebarNavPreference { visible: boolean; } -export interface BuiltinSidebarNavItem { +export interface BuiltinSidebarNavItem
{ kind: "builtin"; - key: BuiltinSidebarNavId; - id: BuiltinSidebarNavId; + key: BuiltinSidebarItemId
; + id: BuiltinSidebarItemId
; visible: boolean; } @@ -23,16 +36,19 @@ export interface PluginSidebarNavItem { visible: boolean; } -export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem; +export type SidebarNavItem
= + | BuiltinSidebarNavItem
+ | PluginSidebarNavItem; -const BUILTIN_LABEL_KEYS: Record = { +const BUILTIN_LABEL_KEYS: Record = { "new-workspace": "sidebar.actions.newWorkspace", history: "sidebar.sections.sessions", search: "sidebar.sections.search", schedules: "sidebar.sections.schedules", + usage: "sidebar.footer.usage", }; -export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string { +export function builtinSidebarNavLabelKey(id: BuiltinSidebarItemId): string { return BUILTIN_LABEL_KEYS[id]; } @@ -41,35 +57,50 @@ export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string { * Both the sidebar row and the Appearance settings row read the badge from here so the * two never disagree about which shortcut belongs to which item. */ -const BUILTIN_SHORTCUT_ACTIONS: Record = { +const BUILTIN_SHORTCUT_ACTIONS: Record = { "new-workspace": "new-workspace", history: null, search: "toggle-command-center", schedules: null, + usage: null, }; -export function builtinSidebarNavShortcutAction(id: BuiltinSidebarNavId): string | null { +export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): string | null { return BUILTIN_SHORTCUT_ACTIONS[id]; } +/** 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): boolean { + return !HIDDEN_BY_DEFAULT.has(id); +} + export function pluginSidebarNavKey( group: Pick, ): string { return `plugin:${group.pluginId}:${group.contributionId}`; } -function isBuiltinSidebarNavId(key: string): key is BuiltinSidebarNavId { - return (BUILTIN_SIDEBAR_NAV_IDS as readonly string[]).includes(key); +function isBuiltinSidebarItemId
( + section: Section, + key: string, +): key is BuiltinSidebarItemId
{ + const ids: readonly string[] = BUILTIN_SIDEBAR_ITEM_IDS[section]; + return ids.includes(key); } -export function resolveSidebarNavItems(input: { +export function resolveSidebarNavItems
(input: { + section: Section; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; -}): SidebarNavItem[] { +}): SidebarNavItem
[] { + const builtinIds: readonly BuiltinSidebarItemId
[] = + BUILTIN_SIDEBAR_ITEM_IDS[input.section]; const groupsByKey = new Map( input.pluginGroups.map((group) => [pluginSidebarNavKey(group), group] as const), ); - const items: SidebarNavItem[] = []; + const items: SidebarNavItem
[] = []; const placed = new Set(); for (const preference of input.preferences) { @@ -78,7 +109,7 @@ export function resolveSidebarNavItems(input: { if (group) { placed.add(preference.key); items.push({ kind: "plugin", key: preference.key, group, visible: preference.visible }); - } else if (isBuiltinSidebarNavId(preference.key)) { + } else if (isBuiltinSidebarItemId(input.section, preference.key)) { placed.add(preference.key); items.push({ kind: "builtin", @@ -89,9 +120,14 @@ export function resolveSidebarNavItems(input: { } } - for (const id of BUILTIN_SIDEBAR_NAV_IDS) { + for (const id of builtinIds) { if (placed.has(id)) continue; - items.push({ kind: "builtin", key: id, id, visible: true }); + items.push({ + kind: "builtin", + key: id, + id, + visible: builtinVisibleByDefault(id), + }); } for (const [key, group] of groupsByKey) { if (placed.has(key)) continue; 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 634b9f96e7..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,70 +1,74 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; +import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; -import { groupPluginSidebarContributions } from "@/plugins/sidebar-groups"; +import { groupPluginSidebarItems } from "@/plugins/sidebar-groups"; import { moveSidebarNavItem, resolveSidebarNavItems, setSidebarNavItemVisible, type SidebarNavItem, + type SidebarSection, } from "./model"; -export interface UseSidebarNavItemsReturn { - /** Every top-level item in display order, hidden ones included. */ - items: SidebarNavItem[]; +const PREFERENCE_FIELDS = { + header: "sidebarNavItems", + footer: "sidebarFooterItems", +} as const satisfies Record; + +export interface UseSidebarNavItemsReturn
{ + /** Every item in the section in display order, hidden ones included. */ + items: SidebarNavItem
[]; setVisible: (key: string, visible: boolean) => void; move: (key: string, direction: "up" | "down") => void; } -export function useSidebarNavItems(): UseSidebarNavItemsReturn { +export function useSidebarNavItems
( + section: Section, +): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); const { settings, updateSettings } = useAppSettings(); - const preferences = settings.sidebarNavItems; - const pluginGroups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]); + const field = PREFERENCE_FIELDS[section]; + const preferences = settings[field]; + const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => - resolveSidebarNavItems({ - pluginGroups, - preferences, - }), - [pluginGroups, preferences], + () => resolveSidebarNavItems({ section, pluginGroups, preferences }), + [pluginGroups, preferences, section], ); const setVisible = useCallback( (key: string, visible: boolean) => { void updateSettings((current) => { - const previous = current.sidebarNavItems; - const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous }); + const previous = current[field]; + const currentItems = resolveSidebarNavItems({ + section, + pluginGroups, + preferences: previous, + }); return { - sidebarNavItems: setSidebarNavItemVisible({ - items: currentItems, - key, - visible, - previous, - }), + [field]: setSidebarNavItemVisible({ items: currentItems, key, visible, previous }), }; }); }, - [pluginGroups, updateSettings], + [field, pluginGroups, section, updateSettings], ); const move = useCallback( (key: string, direction: "up" | "down") => { void updateSettings((current) => { - const previous = current.sidebarNavItems; - const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous }); + const previous = current[field]; + const currentItems = resolveSidebarNavItems({ + section, + pluginGroups, + preferences: previous, + }); return { - sidebarNavItems: moveSidebarNavItem({ - items: currentItems, - key, - direction, - previous, - }), + [field]: moveSidebarNavItem({ items: currentItems, key, direction, previous }), }; }); }, - [pluginGroups, 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 e042322d9a..601030562f 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,9 +1,9 @@ -import { useCallback, useMemo, type ReactElement } from "react"; +import { useCallback, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { Archive, Unlink } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -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 { @@ -45,13 +46,15 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; /** Leading and action glyphs share one size so rows keep a single icon column. */ const ROW_ICON_SIZE = 14; -function buildRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { +function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { + const icon = useProviderIcon(row.provider, serverId); const data = buildSubagentRowPresentationData(row); return { ...data, tooltip: data.label, modified: false, - icon: getProviderIcon(row.provider, serverId), + showCloseButton: getPanelManifest(data.kind).showCloseButton, + icon, }; } @@ -185,7 +188,7 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useMemo(() => buildRowPresentation(row, serverId), [row, serverId]); + const presentation = useRowPresentation(row, serverId); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { 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/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts index b8541bcfe5..d2ef76fefd 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest"; import { getTerminalVirtualKeyboardControlId, + resolveTerminalVirtualKeyboardRows, + shouldShowTerminalVirtualKeyBar, shouldShowTerminalFloatingCopyAction, shouldShowTerminalPasteAction, TERMINAL_VIRTUAL_KEYBOARD_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, type TerminalVirtualKeyboardControl, } from "./terminal-virtual-keyboard"; @@ -41,6 +45,38 @@ function controlsByType( } describe("terminal virtual keyboard policy", () => { + it("keeps terminal controls on wide native touch screens with either IME state", () => { + expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: false })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: true })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: true })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: false })).toBe(false); + }); + + it("selects the same controls in narrow and wide row arrangements", () => { + const ids = (rows: readonly (readonly TerminalVirtualKeyboardControl[])[]) => + rows.flatMap((row) => row.map(getTerminalVirtualKeyboardControlId)); + expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS)].sort()).toEqual( + [...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(), + ); + expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS)].sort()).toEqual( + [...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(), + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 0 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 515 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 160 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 698 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: true, availableWidth: 700 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + }); it("does not expose redundant Space or Backspace controls", () => { expect(controlIds()).toEqual([ "terminal-key-esc", diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts index f931881bce..3dd244fa95 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -41,6 +41,68 @@ export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ ], ] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; +export const TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS = [ + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, + { type: "modifier", modifier: "ctrl" }, + { type: "modifier", modifier: "alt" }, + { type: "modifier", modifier: "shift" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, + { type: "paste" }, + { type: "keyboardToggle" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, + ], +] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; + +export const TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS = [ + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, + { type: "modifier", modifier: "ctrl" }, + ], + [ + { type: "modifier", modifier: "alt" }, + { type: "modifier", modifier: "shift" }, + { type: "keyboardToggle" }, + ], + [ + { type: "paste" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, + ], + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, + ], +] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; + +const TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH = 660; +const TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH = 300; + +export function resolveTerminalVirtualKeyboardRows(input: { + isCompact: boolean; + availableWidth: number; +}) { + if (input.availableWidth > 0 && input.availableWidth < TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH) { + return TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS; + } + return !input.isCompact && input.availableWidth >= TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH + ? TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS + : TERMINAL_VIRTUAL_KEYBOARD_ROWS; +} + +export function shouldShowTerminalVirtualKeyBar(input: { + isNative: boolean; + isCompact: boolean; +}): boolean { + return input.isNative || input.isCompact; +} + export function getTerminalVirtualKeyboardControlId( control: TerminalVirtualKeyboardControl, ): string { 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/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index 77990b3341..b3b5bf39eb 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, - type BottomSheetScrollViewMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet"; import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({ visible, onClose, }); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); + const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 4788133e64..0962c02c7c 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -1,6 +1,8 @@ +import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import { normalizeHostPort, normalizeLoopbackToLocalhost, + shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints"; import { DirectTcpHostConnectionSchema, @@ -354,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null { } } +export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection { + // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. + const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + const relayEndpoint = normalizeHostPort(offer.relay.endpoint); + return { + id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, + type: "relay", + relayEndpoint, + useTls, + daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + }; +} + export function createRemoteSshHostConnection(input: { host: string; sshPort?: number; diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx new file mode 100644 index 0000000000..a31ae89478 --- /dev/null +++ b/packages/app/src/usage/agent-usage.tsx @@ -0,0 +1,66 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import { useUsageDisplay } from "./display"; +import { useAgentUsage } from "./queries"; +import type { UsageReportEntry } from "./types"; + +/** + * The usage of the account an agent runs under, for its context window details: one card per + * report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an + * agent with no account to report. + */ +export function AgentUsage({ + serverId, + agentId, + refreshable, +}: { + serverId: string; + agentId: string; + /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ + refreshable: boolean; +}) { + const view = useAgentUsage(serverId, agentId); + if (view.kind === "none") return null; + return ( + <> + + {view.kind === "ready" ? ( + + ) : ( + + {view.kind === "loading" ? usageCopy.loading : view.message} + + )} + + ); +} + +function AgentUsageCards({ + serverId, + reports, + refreshable, +}: { + serverId: string; + reports: readonly UsageReportEntry[]; + refreshable: boolean; +}) { + const { display } = useUsageDisplay(reports); + return reports.map((entry) => ( + + )); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { height: 1, backgroundColor: theme.colors.borderAccent }, + message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 8bf3ad9d83..751193eec0 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; @@ -9,19 +10,20 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { const { used, remaining, limit, unit } = balance; + const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null; - const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—"; - return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct }; + const usedText = usedAmount != null ? format(usedAmount) : "—"; + return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null }; + return { amountText: `${format(remaining)} left`, usedPct: null }; } if (used != null) { - return { amountText: formatAmount(used, unit), usedPct: null }; + return { amountText: format(used), usedPct: null }; } return { amountText: "—", usedPct: null }; } @@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) { } export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - const { amountText, usedPct } = resolveBalance(balance); + const { i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 9fd9292ad3..4e894108ef 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -1,24 +1,25 @@ -import { RefreshCw } from "lucide-react-native"; -import { useMemo } from "react"; +import { RotateCw } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; import { Text, View, type StyleProp, type TextStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { + extraMutedIconColorMapping, iconButtonChromeGlyphSize, - mutedIconColorMapping, smallIconButtonChromeFrameSize, } from "@/components/ui/icon-button-chrome"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; import { StatusBadge } from "@/components/ui/status-badge"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { useCompactTimeAgo } from "@/hooks/use-time-ago"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; import { formatUsageFreshness, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; -import type { UsageReport, UsageReportEntry } from "./types"; +import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; import { UsageWindowBar } from "./window-bar"; function statusText(report: UsageReport): string | null { @@ -26,27 +27,47 @@ function statusText(report: UsageReport): string | null { return report.status === "error" ? "Error" : "Unavailable"; } -const ThemedRefreshIcon = withUnistyles(RefreshCw); +function reportContent(report: UsageReport) { + if (report.status === "available") + return { + windows: report.windows, + balances: report.balances ?? [], + details: report.details ?? [], + message: undefined, + }; + const message = + report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; + return { windows: [], balances: [], details: [], message }; +} + +const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); 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; - const balances = usage.balances ?? []; - const details = usage.details ?? []; + const { windows, balances, details, message } = reportContent(usage); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -62,13 +83,15 @@ export function UsageCard({ ); return ( - + {entry.sourceLabel} - {usage.planLabel ? : null} + {usage.status === "available" && usage.planLabel ? ( + + ) : null} {status ? ( @@ -76,25 +99,33 @@ export function UsageCard({ {status} ) : null} - + {refreshable ? ( + + ) : null} - {usage.error ? ( + {message ? ( - {usage.error} + {message} ) : null} - {usage.windows.length > 0 || balances.length > 0 ? ( + {windows.length > 0 || balances.length > 0 ? ( - {usage.windows.map((window) => ( - + {windows.map((window) => ( + ))} {balances.map((balance) => ( @@ -141,6 +172,36 @@ export function UsageCard({ ); } +function CardWindowBar({ + entry, + window, + display, + pinnable, +}: { + entry: UsageReportEntry; + window: UsageWindow; + display: UsageDisplay; + pinnable: boolean; +}) { + const pin = useMemo( + () => ({ sourceId: entry.sourceId, windowId: window.id }), + [entry.sourceId, window.id], + ); + const { togglePin } = display; + const toggle = useCallback(() => togglePin(pin), [pin, togglePin]); + return ( + + ); +} + /** Refreshes this one report. Its tooltip says when the report on screen was fetched. */ function UsageRefreshButton({ sourceLabel, @@ -156,7 +217,7 @@ function UsageRefreshButton({ compact: boolean; }) { const isPending = refreshState === "pending"; - const iconSize = iconButtonChromeGlyphSize("small", compact); + const iconSize = paneContentToolbarIconSize(compact); const freshness = useMemo( () => ( {isPending ? ( - + ) : ( - + )} ); @@ -217,8 +278,9 @@ const styles = StyleSheet.create((theme) => ({ container: { gap: theme.spacing[3], }, + // The gap is one step under the card padding: window rows add their own vertical padding. containerPadded: { - gap: theme.spacing[4], + gap: theme.spacing[3], paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, @@ -259,8 +321,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + // Window rows carry their own vertical padding, which already separates them. bars: { - gap: theme.spacing[3], + gap: theme.spacing[1], }, details: { gap: theme.spacing[1], diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx deleted file mode 100644 index 26cd77793c..0000000000 --- a/packages/app/src/usage/composer-pill.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { MenuTriggerState } from "@/components/ui/menu"; -import { - isToolbarLabelTriggerHighlighted, - toolbarLabelTriggerStyle, - toolbarLabelTriggerTextStyle, -} from "@/components/ui/toolbar-label-trigger"; -import { UsageCard } from "./card"; -import { usageCopy } from "./copy"; -import type { UsagePill } from "./model"; -import { useAgentUsage } from "./queries"; -import { UsageSourceIcon } from "./source-icon"; - -function pillTriggerStyle(state: MenuTriggerState) { - return [toolbarLabelTriggerStyle(state), styles.trigger]; -} - -/** - * The agent's plan usage beside the composer controls. Opens the cached usage card; - * only the card's Refresh asks the source for a new report. - */ -export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) { - const { pill, entry } = useAgentUsage(serverId, agentId); - - if (!pill || !entry) return null; - - return ( - - - {(state) => ( - - )} - - - - - - - - ); -} - -function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) { - return ( - <> - - {pill.text ? ( - - {pill.text} - - ) : null} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - trigger: { - height: 28, - flexShrink: 0, - }, - popover: { - padding: theme.spacing[3], - }, -})); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx new file mode 100644 index 0000000000..ccd58fb63e --- /dev/null +++ b/packages/app/src/usage/controls.tsx @@ -0,0 +1,78 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { HostFilter } from "@/components/hosts/host-filter"; +import { useMemo, type ReactElement } from "react"; +import { UsageRefreshButton } from "./refresh-button"; +import type { UsageHost } from "./model"; +import { useHostUsage } from "./queries"; +import type { UsageView } from "./types"; + +/** The hosts to choose between, and which one is shown. */ +export interface UsageHostSelection { + hosts: UsageHost[]; + serverId: string; + onSelect: (serverId: string) => void; +} + +/** + * The controls on the right of every usage title row: the host filter when there is more than one + * host, and Refresh all. A host that cannot report usage keeps only the host filter. + */ +export function UsageControls({ + view, + onRefresh, + hostSelection, +}: { + view: UsageView; + onRefresh: () => void; + hostSelection?: UsageHostSelection; +}) { + const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); + return ( + + {hostSelection && hostSelection.hosts.length > 1 ? ( + + ) : null} + {view.kind === "unavailable" ? null : ( + + )} + + ); +} + +function usageHostOptionTestID(serverId: string): string { + return `usage-host-filter-item-${serverId}`; +} + +const styles = StyleSheet.create((theme) => ({ + controls: { + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); + +/** + * One host's usage and the title-row controls that go with it, for the Usage screen and the + * compact usage sheet. + */ +export function useHostUsageWithControls(hostSelection: UsageHostSelection): { + view: UsageView; + refresh: () => void; + controls: ReactElement; +} { + const { view, refresh } = useHostUsage(hostSelection.serverId); + const controls = useMemo( + () => , + [hostSelection, refresh, view], + ); + return { view, refresh, controls }; +} diff --git a/packages/app/src/usage/copy.test.ts b/packages/app/src/usage/copy.test.ts new file mode 100644 index 0000000000..a5176eba93 --- /dev/null +++ b/packages/app/src/usage/copy.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { usageCopy } from "./copy"; + +test.each([ + [ + { kind: "expired", expiresAt: "2026-10-01T09:00:00Z", refreshedBy: "claude" }, + "Login expired 3h ago. Run claude to refresh it.", + ], + [{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z" }, "Login expired 3h ago. Sign in again."], + [ + { kind: "rejected", status: 403, refreshedBy: "codex" }, + "Login rejected (HTTP 403). Run codex to refresh it.", + ], + [{ kind: "rejected", status: 401 }, "Login rejected (HTTP 401). Sign in again."], + [{ kind: "no_quota", detail: "No active coding plan" }, "No active coding plan"], +] as const)("problem %j", (problem, sentence) => { + expect(usageCopy.problem(problem, new Date("2026-10-01T12:00:00Z"))).toBe(sentence); +}); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index ba0c8b0a46..639272283b 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -1,9 +1,23 @@ +import type { UsageProblem } from "@getpaseo/protocol/messages"; +import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; + // User-facing copy for the usage surfaces, kept in one file so localization is a // single-file change. export const usageCopy = { + problem: (problem: UsageProblem, now: Date = new Date()): string => { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); + return `Login expired ${ago}. ${remedy}`; + }, title: "Usage", planUsage: "Plan usage", + options: "Settings", refresh: "Refresh", + refreshAll: "Refresh all", refreshing: "Refreshing...", refreshFailed: "Unable to refresh usage", updated: "Updated", @@ -11,8 +25,16 @@ export const usageCopy = { empty: "No usage data", noHosts: "No connected hosts", errorTitle: "Unable to load usage", - hostUnavailable: "Connect to this host to see usage", - hostUpgradeRequired: "Update the host to see usage", + agentError: (reason: string) => `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", retry: "Try again", + pin: "Pin", + unpin: "Unpin", + displayAs: "Percentages", + displayUsed: "Used", + displayRemaining: "Remaining", + showInSidebar: "Summary in sidebar", + showInSidebarHint: "Pinned windows show in the sidebar footer", } as const; diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts new file mode 100644 index 0000000000..5b4e534693 --- /dev/null +++ b/packages/app/src/usage/display.ts @@ -0,0 +1,62 @@ +import { useCallback, useMemo } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; +import { useUsageHostId } from "./hosts"; +import { useUsageHostReports } from "./queries"; +import { + isUsagePinned, + setUsageDisplayAs, + toggleUsagePin, + type UsageDisplayAs, + 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 { + displayAs: UsageDisplayAs; + setDisplayAs: (displayAs: UsageDisplayAs) => void; + isPinned: (pin: UsagePin) => boolean; + togglePin: (pin: UsagePin) => void; +} + +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 setDisplayAs = useCallback( + (displayAs: UsageDisplayAs) => { + void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) })); + }, + [updateSettings], + ); + const togglePin = useCallback( + (pin: UsagePin) => { + void updateSettings((current) => ({ + usage: toggleUsagePin({ preferences: current.usage, pin, reports }), + })); + }, + [updateSettings, reports], + ); + const display = useMemo( + () => ({ + displayAs: preferences.displayAs, + setDisplayAs, + isPinned: (pin) => isUsagePinned(preferences, pin, reports), + togglePin, + }), + [preferences, reports, setDisplayAs, togglePin], + ); + return { preferences, display }; +} diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts new file mode 100644 index 0000000000..88fe227ae6 --- /dev/null +++ b/packages/app/src/usage/format.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount } from "./format"; + +describe("formatAmount", () => { + it("groups thousands in the app's language", () => { + expect(formatAmount(12345, "credits", "en")).toBe("12,345"); + expect(formatAmount(12345, "requests", "en")).toBe("12,345"); + expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345"); + }); + + it("formats dollars as the language writes currency", () => { + expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); + expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); + }); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index ac8bffefd0..5adef2154d 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,4 +1,5 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; +import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; export function clampPct(value: number): number { @@ -9,6 +10,11 @@ export function formatPct(value: number): string { return `${Math.round(clampPct(value))}%`; } +/** "31%" of the window used, or "69% left" of it. */ +export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string { + return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`; +} + function relativeDuration(iso: string): string | null { const diffMs = new Date(iso).getTime() - Date.now(); if (!Number.isFinite(diffMs)) return null; @@ -28,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null return rel === "now" ? "resetting now" : `resets ${rel}`; } -export function formatAmount(value: number, unit: UsageBalanceUnit): string { +/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ +export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { switch (unit) { case "usd": - return `$${value.toFixed(2)}`; + return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": return formatTokenCount(value); default: - return value.toLocaleString(); + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index 54dd4ef939..927d32e7d8 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,15 +1,18 @@ import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; /** A host's usage reports, for its settings page. */ export function HostUsageSection({ serverId }: { serverId: string }) { const { view, refresh } = useHostUsage(serverId); + const { display } = useUsagePreferences(serverId); return ( diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts new file mode 100644 index 0000000000..450e0a6157 --- /dev/null +++ b/packages/app/src/usage/hosts.ts @@ -0,0 +1,59 @@ +import { useCallback, useMemo } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; +import { + useActiveWorkspaceSelection, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { + resolveUsageHostId, + resolveUsageScreenHostId, + type UsageHost, + type UsageHostChoice, +} from "./model"; +import { setUsageHost } from "./preferences"; +import { useUsageHosts } from "./queries"; + +/** The active workspace's host; off a workspace route, the last workspace visited. */ +function useActiveServerId(): string | null { + const active = useActiveWorkspaceSelection(); + const last = useLastWorkspaceSelection(); + return active?.serverId ?? last?.serverId ?? null; +} + +function useUsageHostChoice(): UsageHostChoice { + const { settings } = useAppSettings(); + return { + pickedServerId: settings.usage.serverId, + activeServerId: useActiveServerId(), + hosts: useUsageHosts(), + }; +} + +/** The host the sidebar Usage row reads, or null when none reports usage. */ +export function useUsageHostId(): string | null { + return resolveUsageHostId(useUsageHostChoice()); +} + +/** + * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick + * is saved on the device, so the sidebar Usage row and later visits show the same host. + */ +export function useUsageHostSelection(): { + serverId: string | null; + connectedHosts: UsageHost[]; + select: (serverId: string) => void; +} { + const { updateSettings } = useAppSettings(); + const choice = useUsageHostChoice(); + const connectedHosts = useMemo( + () => choice.hosts.filter((host) => host.isConnected), + [choice.hosts], + ); + const select = useCallback( + (serverId: string) => { + void updateSettings((current) => ({ usage: setUsageHost(current.usage, serverId) })); + }, + [updateSettings], + ); + return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; +} 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 54f745a04d..ab1e28b38e 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,3 +1,5 @@ -export { UsageComposerPill } from "./composer-pill"; +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 91aa4d4b50..10e76ba57f 100644 --- a/packages/app/src/usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -1,32 +1,41 @@ -import { Fragment } from "react"; import { View } from "react-native"; 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). Window rows pin while the sidebar summary is on. */ export function UsageList({ serverId, reports, + display, }: { serverId: string; reports: UsageReportEntry[]; + display: UsageDisplay; }) { + const { inSidebar } = useUsageInSidebar(); return ( - - {reports.map((entry, index) => ( - - {index > 0 ? : null} - - + + {reports.map((entry) => ( + + + ))} ); } const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - backgroundColor: theme.colors.border, + list: { + gap: theme.spacing[3], }, })); diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx new file mode 100644 index 0000000000..37842cafb5 --- /dev/null +++ b/packages/app/src/usage/meter.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct } from "./format"; +import type { UsageTone } from "./types"; + +function fillToneStyle(tone: UsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +/** + * A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead, + * for summaries where a row of coloured bars would be noise. The caller sizes the track. + */ +export function UsageMeter({ + percent, + tone, + quiet = false, + style, +}: { + percent: number; + tone: UsageTone; + quiet?: boolean; + style?: StyleProp; +}) { + const fillWidth = clampPct(percent); + const fillStyle = useMemo>( + () => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }], + [fillWidth, quiet, tone], + ); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + // Follows the track, so a caller that sizes the track sizes the bar. + fill: { + height: "100%", + borderRadius: theme.borderRadius.full, + }, + fillQuiet: { + backgroundColor: theme.colors.surface4, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 54ebc4df87..1276157120 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from "vitest"; import { + displayPercent, formatUsageFreshness, - groupUsageByHost, + resolveAgentUsageView, replaceReport, - headlineWindow, - resolveUsagePill, resolveUsageRefresh, + resolveUsageHostId, + resolveUsageScreenHostId, resolveUsageView, + settleReports, + upsertReport, + usageWindowRowLabel, + type UsageHost, type UsageQueryState, } from "./model"; import type { UsageReportEntry, UsageWindow } from "./types"; @@ -36,85 +41,31 @@ function ready(data: UsageReportEntry[]): UsageQueryState { return { data, error: null, isFetching: false }; } -describe("resolveUsagePill", () => { - it("shows the source icon and the headline window percent", () => { - const pill = resolveUsagePill({ - supportsUsage: true, - entry: entry({ - icon: "", - windows: [ - { id: "session", label: "Session", usedPct: 12 }, - { id: "weekly", label: "Weekly", usedPct: 64.6, headline: true }, - ], - }), - }); - - expect(pill).toEqual({ icon: "", sourceLabel: "Fixture source", text: "65%" }); - }); - - it("derives the headline percent from remaining percent", () => { - const pill = resolveUsagePill({ - supportsUsage: true, - entry: entry({ - windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }], - }), - }); - - expect(pill?.text).toBe("70%"); - }); - - it("shows the plan label when no window is the headline", () => { - const pill = resolveUsagePill({ - supportsUsage: true, - entry: entry({ - planLabel: "Pro", - windows: [{ id: "session", label: "Session", usedPct: 40 }], - }), - }); - - expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" }); - }); - - it("shows only the icon with neither a headline window nor a plan label", () => { - const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) }); - - expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null }); - }); - - it("is hidden when the agent has no usage report", () => { - expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull(); - }); - - it("is hidden on a host without usage sources, whatever data is cached", () => { - const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] }); - - expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull(); - }); -}); - -describe("headlineWindow", () => { - it("does not fall back to the first window", () => { - expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe( - null, - ); - }); -}); - describe("resolveUsageView", () => { it("asks for a host update when the host lacks usage sources", () => { expect( - resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }), - ).toEqual({ kind: "unavailable", message: "Update the host to see usage" }); + resolveUsageView({ + hostLabel: "Laptop", + isConnected: true, + supportsUsage: false, + query: ready([entry({})]), + }), + ).toEqual({ kind: "unavailable", message: "Update Laptop to see usage" }); }); it("asks for a connection before anything else", () => { expect( - resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }), - ).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" }); + resolveUsageView({ + hostLabel: "Laptop", + isConnected: false, + supportsUsage: false, + query: undefined, + }), + ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); }); it("moves from loading to ready to error", () => { - const base = { isConnected: true, supportsUsage: true }; + const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true }; expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); expect( resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }), @@ -128,49 +79,108 @@ describe("resolveUsageView", () => { }); }); -describe("groupUsageByHost", () => { - it("groups reports under each connected host in host order", () => { - const first = entry({ sourceId: "one" }); - const second = entry({ sourceId: "two" }); - const groups = groupUsageByHost( - [ - { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true }, - { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, - { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true }, - ], - new Map([ - ["a", ready([first])], - ["b", ready([second, first])], - ]), - ); +const hosts: UsageHost[] = [ + { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, + { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, + { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true }, + { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true }, +]; - expect(groups).toEqual([ - { - serverId: "b", - label: "Beta", - view: { kind: "ready", reports: [second, first], isRefreshing: false }, - }, - { - serverId: "a", - label: "Alpha", - view: { kind: "ready", reports: [first], isRefreshing: false }, - }, - ]); +describe("resolveUsageHostId", () => { + const choose = (pickedServerId: string | null, activeServerId: string | null) => + resolveUsageHostId({ pickedServerId, activeServerId, hosts }); + + it("reads the picked host over the active workspace's host", () => { + expect(choose("a", "b")).toBe("a"); }); - it("shows the update message in an old host's group", () => { - const groups = groupUsageByHost( - [ - { serverId: "new", label: "New", isConnected: true, supportsUsage: true }, - { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, - ], - new Map([["new", ready([])]]), - ); + it("reads the active workspace's host when nothing usable is picked", () => { + expect(choose(null, "b")).toBe("b"); + expect(choose("offline", "b")).toBe("b"); + expect(choose("old", "b")).toBe("b"); + }); - expect(groups.map((group) => [group.label, group.view])).toEqual([ - ["New", { kind: "ready", reports: [], isRefreshing: false }], - ["Old", { kind: "unavailable", message: "Update the host to see usage" }], - ]); + it("falls back to the first connected host that reports usage", () => { + expect(choose(null, null)).toBe("a"); + expect(choose(null, "offline")).toBe("a"); + expect(choose(null, "old")).toBe("a"); + }); + + it("has no host when none reports usage", () => { + expect( + resolveUsageHostId({ + pickedServerId: "old", + activeServerId: "old", + hosts: hosts.slice(0, 2), + }), + ).toBeNull(); + }); +}); + +describe("resolveUsageScreenHostId", () => { + it("shows the picked host while it stays connected, even one that cannot report usage", () => { + expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + "old", + ); + expect( + resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), + ).toBe("b"); + }); + + it("otherwise shows the host the sidebar row reads", () => { + for (const pickedServerId of [null, "a", "offline"]) { + for (const activeServerId of ["b", null, "old", "offline"]) { + const choice = { pickedServerId, activeServerId, hosts }; + expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice)); + } + } + }); + + it("shows the first connected host when none reports usage, so the screen says to update it", () => { + expect( + resolveUsageScreenHostId({ + pickedServerId: null, + activeServerId: null, + hosts: hosts.slice(0, 2), + }), + ).toBe("old"); + expect( + resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + ).toBeNull(); + }); +}); + +describe("usageWindowRowLabel", () => { + it("names the window, its percent and its reset after what pinning it pins", () => { + expect( + usageWindowRowLabel({ + pinLabel: "Pin Claude Session", + value: "31%", + trailing: "resets in 2h", + }), + ).toBe("Pin Claude Session, 31% · resets in 2h"); + expect(usageWindowRowLabel({ pinLabel: "Pin Codex Weekly", value: "—", trailing: null })).toBe( + "Pin Codex Weekly, —", + ); + }); +}); + +describe("displayPercent", () => { + const window = (input: Partial): UsageWindow => ({ + id: "weekly", + label: "Weekly", + ...input, + }); + + it("reads the share used or the share left", () => { + expect(displayPercent(window({ usedPct: 31 }), "used")).toBe(31); + expect(displayPercent(window({ usedPct: 31 }), "remaining")).toBe(69); + expect(displayPercent(window({ remainingPct: 40 }), "used")).toBe(60); + expect(displayPercent(window({ usedPct: 50, remainingPct: 45 }), "remaining")).toBe(45); + }); + + it("has no percent when the window reports none", () => { + expect(displayPercent(window({}), "remaining")).toBeNull(); }); }); @@ -209,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 c3cddc70fe..2b2e4c54cf 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,6 +1,7 @@ import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; -import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; +import type { UsageDisplayAs } from "./preferences"; +import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; export function usedPercent(window: UsageWindow): number | null { if (window.usedPct != null) return window.usedPct; @@ -8,34 +9,26 @@ export function usedPercent(window: UsageWindow): number | null { return null; } -/** The window the source marked as its headline. Sources own that choice; there is no fallback. */ -export function headlineWindow(report: UsageReport): UsageWindow | null { - return report.windows.find((window) => window.headline === true) ?? null; +/** The percent a window shows under the user's used/remaining preference. */ +export function displayPercent(window: UsageWindow, displayAs: UsageDisplayAs): number | null { + if (displayAs === "used") return usedPercent(window); + if (window.remainingPct != null) return window.remainingPct; + const used = usedPercent(window); + return used == null ? null : 100 - used; } -export interface UsagePill { - icon: string | null; - sourceLabel: string; - /** Headline percent, else the plan label, else nothing beside the icon. */ - text: string | null; -} - -export function resolveUsagePill(input: { - supportsUsage: boolean; - entry: UsageReportEntry | null | undefined; -}): UsagePill | null { - const { supportsUsage, entry } = input; - if (!supportsUsage || !entry) return null; - const window = headlineWindow(entry.report); - const percent = window ? usedPercent(window) : null; - return { - icon: entry.icon ?? null, - sourceLabel: entry.sourceLabel, - text: - percent != null - ? `${Math.round(Math.max(0, Math.min(100, percent)))}%` - : (entry.report.planLabel ?? null), - }; +/** + * A window row's accessible label: what pinning it pins, then what the row shows. The row is a + * checkbox, so its checked state says whether it is pinned: "Pin Claude Session, 31% · resets in + * 2h", checked. + */ +export function usageWindowRowLabel(input: { + pinLabel: string; + value: string; + trailing: string | null | undefined; +}): string { + const summary = input.trailing ? `${input.value} · ${input.trailing}` : input.value; + return `${input.pinLabel}, ${summary}`; } /** When a report was fetched, from its compact relative time: "Updated 3m ago". */ @@ -68,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; @@ -75,13 +102,16 @@ export interface UsageQueryState { } export function resolveUsageView(input: { + hostLabel: string; isConnected: boolean; supportsUsage: boolean; query: UsageQueryState | undefined; }): UsageView { - const { isConnected, supportsUsage, query } = input; - if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable }; - if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired }; + const { hostLabel, isConnected, supportsUsage, query } = input; + if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) }; + if (!supportsUsage) { + return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) }; + } if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; } @@ -94,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; @@ -101,26 +156,32 @@ export interface UsageHost { supportsUsage: boolean; } -export interface UsageHostGroup { - serverId: string; - label: string; - view: UsageView; +/** Where usage looks for its host: the user's saved pick, then the workspace they are in. */ +export interface UsageHostChoice { + pickedServerId: string | null; + activeServerId: string | null; + hosts: readonly UsageHost[]; } -/** One group per connected host, in host order. */ -export function groupUsageByHost( - hosts: readonly UsageHost[], - queries: ReadonlyMap, -): UsageHostGroup[] { - return hosts - .filter((host) => host.isConnected) - .map((host) => ({ - serverId: host.serverId, - label: host.label, - view: resolveUsageView({ - isConnected: true, - supportsUsage: host.supportsUsage, - query: queries.get(host.serverId), - }), - })); +/** + * The host the sidebar Usage row reads: the picked host, else the active workspace's host, else the + * first host. Each only while it is connected and reports usage. + */ +export function resolveUsageHostId(choice: UsageHostChoice): string | null { + const reporting = choice.hosts.filter((host) => host.isConnected && host.supportsUsage); + const find = (serverId: string | null) => reporting.find((host) => host.serverId === serverId); + return ( + (find(choice.pickedServerId) ?? find(choice.activeServerId) ?? reporting[0])?.serverId ?? null + ); +} + +/** + * The host the Usage screen shows: the picked host while it is connected, even one that cannot + * report usage so the screen says to update it; else the sidebar row's host; else the first + * connected host. + */ +export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { + const connected = choice.hosts.filter((host) => host.isConnected); + const picked = connected.find((host) => host.serverId === choice.pickedServerId); + return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx new file mode 100644 index 0000000000..cf2666f897 --- /dev/null +++ b/packages/app/src/usage/options.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import { + SettingsCard, + SettingsCollapsibleRow, + SettingsRow, + SettingsSwitch, +} from "@/components/settings"; +import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +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[] = [ + { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, + { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, +]; + +/** + * The usage options, folded above the reports: whether the sidebar shows the summary (the same + * switch as Settings > Sidebar) and whether percents read as used or remaining. + */ +export function UsageOptions({ display }: { display: UsageDisplay }) { + const { inSidebar, setInSidebar } = useUsageInSidebar(); + return ( + + + + + + + + + + + ); +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts new file mode 100644 index 0000000000..9965251595 --- /dev/null +++ b/packages/app/src/usage/pinned.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import type { UsagePreferences } from "./preferences"; +import { + choosePinnedUsageLayout, + resolvePinnedUsage, + type PinnedUsageSource, + type PinnedUsageWindow, +} from "./pinned"; +import type { UsageReportEntry, UsageWindow } from "./types"; + +function report(input: { + sourceId: string; + sourceLabel: string; + account?: string; + icon?: string; + windows: UsageWindow[]; +}): UsageReportEntry { + return { + id: `${input.sourceId}:${input.account ?? "default"}`, + account: input.account ? { label: input.account } : {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: input.sourceId, + sourceLabel: input.sourceLabel, + ...(input.icon ? { icon: input.icon } : {}), + report: { status: "available", windows: input.windows }, + }; +} + +const claude = report({ + sourceId: "claude", + sourceLabel: "Claude", + icon: "", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 }, + ], +}); +const codex = report({ + sourceId: "codex", + sourceLabel: "Codex", + windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }], +}); + +function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] { + return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows); +} + +function preferences( + pinned: UsagePreferences["pins"], + displayAs: UsagePreferences["displayAs"] = "used", +): UsagePreferences { + return { displayAs, pins: pinned, serverId: null }; +} + +describe("choosePinnedUsageLayout", () => { + const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 }; + + it("shows meters only when every window's meter fits beside the labels", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters"); + expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels"); + }); + + it("drops the labels before it lets the line overflow", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents"); + }); + + it("shows labels until the widths are measured", () => { + expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels"); + expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe( + "labels", + ); + }); +}); + +describe("resolvePinnedUsage", () => { + it("defaults to source-wide pins collected from each account", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [ + { id: "empty", label: "Empty" }, + { id: "weekly", label: "Weekly", usedPct: 90 }, + ], + }); + const empty = report({ + sourceId: "empty", + sourceLabel: "Empty", + windows: [{ id: "empty", label: "Empty" }], + }); + expect( + windows([claude, codex, work, empty], preferences(null)).map((item) => [ + item.key, + item.percentText, + ]), + ).toEqual([ + ["claude:default/five-hour", "31%"], + ["claude:default/weekly", "80%"], + ["claude:work/weekly", "90%"], + ["codex:default/weekly", "12%"], + ]); + }); + + it("pins replace defaults, and removing all pins restores defaults", () => { + const reports = [claude, codex]; + expect( + windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( + (item) => item.key, + ), + ).toEqual(["claude:default/weekly"]); + expect(windows(reports, preferences(null)).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "codex:default/weekly", + ]); + }); + + it("groups pinned windows by source in report order, not pin order", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }], + }); + const sources = resolvePinnedUsage( + [claude, codex, work], + preferences([ + { sourceId: "claude", windowId: "weekly" }, + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ]), + ); + + const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key); + expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([ + ["claude:default", ["claude:default/five-hour", "claude:default/weekly"]], + ["claude:work", ["claude:work/weekly"]], + ["codex:default", ["codex:default/weekly"]], + ]); + expect(sources[0]).toEqual({ + key: "claude:default", + icon: "", + windows: [ + { + key: "claude:default/five-hour", + label: "Claude Session 31% used", + shortLabel: "5h", + percent: 31, + percentText: "31%", + tone: "default", + }, + { + key: "claude:default/weekly", + label: "Claude Weekly 80% used", + shortLabel: "wk", + percent: 80, + percentText: "80%", + tone: "warning", + }, + ], + }); + }); + + it("names a window by its label when the source gives no short label", () => { + const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }])); + + expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]); + }); + + it("defaults to every window the source marks as summary, when it marks any", () => { + const marked = report({ + sourceId: "claude", + sourceLabel: "Claude", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true }, + { id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 }, + ], + }); + + expect(windows([marked, codex], preferences(null)).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "claude:default/weekly", + "codex:default/weekly", + ]); + }); + + it("keeps an empty short label empty, for a percent that needs no name", () => { + const opencode = report({ + sourceId: "opencode", + sourceLabel: "OpenCode Go", + windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }], + }); + const items = windows([opencode], preferences(null)); + + expect(items.map((item) => item.shortLabel)).toEqual([""]); + }); + + it("formats the share left when the user reads usage as remaining", () => { + const items = windows( + [claude, codex], + preferences( + [ + { sourceId: "claude", windowId: "five-hour" }, + { sourceId: "codex", windowId: "weekly" }, + ], + "remaining", + ), + ); + + expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]); + expect(items.map((item) => item.label)).toEqual([ + "Claude Session 69% left", + "Codex Weekly 88% left", + ]); + }); + + it("leaves out a pinned window that no report has, or that reports no percent", () => { + const noPercent = report({ + sourceId: "opencode", + sourceLabel: "OpenCode", + windows: [{ id: "monthly", label: "Monthly" }], + }); + const items = windows( + [claude, noPercent], + preferences([ + { sourceId: "claude", windowId: "monthly" }, + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "opencode", windowId: "monthly" }, + ]), + ); + + expect(items).toEqual([]); + }); + + it("shows a pin once per account of the source", () => { + const personal = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "personal", + windows: [{ id: "five-hour", label: "5-hour", usedPct: 10 }], + }); + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }], + }); + const items = windows( + [personal, codex, work], + preferences([{ sourceId: "claude", windowId: "five-hour" }]), + ); + + expect(items.map((item) => [item.label, item.percentText])).toEqual([ + ["Claude (personal) 5-hour 10% used", "10%"], + ["Claude (work) 5-hour 90% used", "90%"], + ]); + }); +}); + +it("keeps unavailable and error reports out of the sidebar summary", () => { + const expired: UsageReportEntry = { + ...claude, + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, + }, + }; + const failed: UsageReportEntry = { + ...codex, + report: { status: "error", error: "Store deleted" }, + }; + expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts new file mode 100644 index 0000000000..547b2c3e48 --- /dev/null +++ b/packages/app/src/usage/pinned.ts @@ -0,0 +1,97 @@ +import { formatPct } from "./format"; +import { displayPercent } from "./model"; +import { effectiveUsagePins, type UsagePreferences } from "./preferences"; +import { windowTone } from "./tone"; +import type { UsageReportEntry, UsageTone } from "./types"; + +/** One summary window of one account, as the sidebar Usage item shows it. */ +export interface PinnedUsageWindow { + key: string; + /** Accessible description, including the source, account, window and percent meaning. */ + label: string; + /** The window's short name ("5h"), empty for none, or its label when the source sends none. */ + shortLabel: string; + /** The share shown, under the user's used/remaining preference. */ + percent: number; + percentText: string; + tone: UsageTone; +} + +/** One account's summary windows, under its source's icon. */ +export interface PinnedUsageSource { + key: string; + icon: string | null; + windows: PinnedUsageWindow[]; +} + +/** How much of each window the one-line summary has room for. */ +export type PinnedUsageLayout = "meters" | "labels" | "percents"; + +// A meter narrower than this is a sliver that reads as noise rather than a share. +export const MIN_METER_WIDTH = 16; +/** The space between a meter and its text. */ +export const METER_GAP = 4; + +/** + * The richest layout that fits on one line: every window's meter, else its short label, else its + * percent alone. Meters are all or none; a line never mixes them. Until the widths are known it + * shows labels, which never flashes meters that turn out not to fit. + */ +export function choosePinnedUsageLayout(input: { + available: number | null; + labelsWidth: number | null; + percentsWidth: number | null; + windowCount: number; +}): PinnedUsageLayout { + const { available, labelsWidth, percentsWidth, windowCount } = input; + if (available === null || labelsWidth === null || percentsWidth === null) return "labels"; + if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters"; + if (labelsWidth <= available) return "labels"; + return percentsWidth < labelsWidth ? "percents" : "labels"; +} + +function describe(entry: UsageReportEntry, windowLabel: string): string { + const account = entry.account.label ? ` (${entry.account.label})` : ""; + return `${entry.sourceLabel}${account} ${windowLabel}`; +} + +/** Reports with every account of a source together, sources in the order they first appear. */ +function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] { + const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))]; + return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId)); +} + +/** + * The sidebar Usage item's summary: one group per account, grouped by source, each with its + * windows in its report's order. Accounts with nothing to show are left out. + */ +export function resolvePinnedUsage( + reports: readonly UsageReportEntry[], + preferences: UsagePreferences, +): PinnedUsageSource[] { + const pins = effectiveUsagePins(preferences, reports); + const meaning = preferences.displayAs === "remaining" ? "left" : "used"; + return groupBySource(reports).flatMap((entry) => { + if (entry.report.status !== "available") return []; + const windows = entry.report.windows + .filter( + (window) => + displayPercent(window, preferences.displayAs) !== null && + pins.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), + ) + .map((window) => { + const percent = displayPercent(window, preferences.displayAs) ?? 0; + const percentText = formatPct(percent); + return { + key: `${entry.id}/${window.id}`, + label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + shortLabel: window.shortLabel ?? window.label, + percent, + percentText, + tone: windowTone(window), + }; + }); + if (windows.length === 0) return []; + return [{ key: entry.id, icon: entry.icon ?? null, windows }]; + }); +} diff --git a/packages/app/src/usage/preferences.test.ts b/packages/app/src/usage/preferences.test.ts new file mode 100644 index 0000000000..43dccc466d --- /dev/null +++ b/packages/app/src/usage/preferences.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_USAGE_PREFERENCES, + isUsagePinned, + setUsageDisplayAs, + setUsageHost, + toggleUsagePin, + UsagePreferencesSchema, +} from "./preferences"; + +const claudeFiveHour = { sourceId: "claude", windowId: "five-hour" }; +const codexWeekly = { sourceId: "codex", windowId: "weekly" }; + +describe("usage preferences", () => { + it("pins windows in the order the user pins them", () => { + const pinned = toggleUsagePin({ + preferences: toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: codexWeekly, + reports: [], + }), + pin: claudeFiveHour, + reports: [], + }); + + expect(pinned.pins).toEqual([codexWeekly, claudeFiveHour]); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "weekly" }, [])).toBe(true); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "five-hour" }, [])).toBe(false); + }); + + it("unpins a pinned window and keeps the others in order", () => { + const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }]; + const unpinned = toggleUsagePin({ + preferences: { displayAs: "used", pins: three, serverId: null }, + pin: claudeFiveHour, + reports: [], + }); + + expect(unpinned.pins).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]); + }); + + it("switches between used and remaining without touching pins", () => { + const preferences = { displayAs: "used" as const, pins: [codexWeekly], serverId: null }; + + expect(setUsageDisplayAs(preferences, "remaining")).toEqual({ + displayAs: "remaining", + pins: [codexWeekly], + serverId: null, + }); + }); + + it("remembers the picked host without touching pins", () => { + expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pins: [codexWeekly] }, "server")).toEqual({ + displayAs: "used", + pins: [codexWeekly], + serverId: "server", + }); + }); + + it("reads preferences saved before hosts could be picked as no pick", () => { + expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual( + { displayAs: "remaining", pins: [codexWeekly], serverId: null }, + ); + }); +}); + +it("default sidebar windows are pinned on cards and the first toggle removes only that row", () => { + const reports = [ + { + id: "claude:default", + sourceId: "claude", + sourceLabel: "Claude", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [ + { id: "five_hour", label: "5-hour", summary: true, usedPct: 31 }, + { id: "weekly", label: "Weekly", summary: true, usedPct: 54 }, + ], + }, + }, + { + id: "codex:default", + sourceId: "codex", + sourceLabel: "Codex", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [{ id: "five_hour", label: "5-hour", summary: true, usedPct: 7 }], + }, + }, + ]; + const pins = [ + { sourceId: "claude", windowId: "five_hour" }, + { sourceId: "claude", windowId: "weekly" }, + { sourceId: "codex", windowId: "five_hour" }, + ]; + for (const pin of pins) expect(isUsagePinned(DEFAULT_USAGE_PREFERENCES, pin, reports)).toBe(true); + const changed = toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: pins[1]!, + reports, + }); + expect(isUsagePinned(changed, pins[1]!, reports)).toBe(false); + expect(isUsagePinned(changed, pins[0]!, reports)).toBe(true); + expect(isUsagePinned(changed, pins[2]!, reports)).toBe(true); + const empty = toggleUsagePin({ + preferences: toggleUsagePin({ preferences: changed, pin: pins[0]!, reports }), + pin: pins[2]!, + reports, + }); + const restored = UsagePreferencesSchema.parse(JSON.parse(JSON.stringify(empty))); + for (const pin of pins) expect(isUsagePinned(restored, pin, reports)).toBe(false); +}); + +it("reads old selections without losing display or host and preserves an explicit empty selection", () => { + for (const [input, pins] of [ + [{ pinned: [] }, null], + [{ pinned: [codexWeekly] }, [codexWeekly]], + [{ pins: [], pinned: [codexWeekly] }, []], + [{ pins: null, pinned: [codexWeekly] }, null], + [{ pins: "bad", pinned: [codexWeekly] }, null], + ] as const) { + expect( + UsagePreferencesSchema.parse({ displayAs: "remaining", serverId: "host", ...input }), + ).toEqual({ displayAs: "remaining", serverId: "host", pins }); + } +}); diff --git a/packages/app/src/usage/preferences.ts b/packages/app/src/usage/preferences.ts new file mode 100644 index 0000000000..e082870af6 --- /dev/null +++ b/packages/app/src/usage/preferences.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { displayPercent } from "./model"; +import type { UsageReportEntry } from "./types"; + +/** Whether percentages read as the share used or the share left. */ +export type UsageDisplayAs = "used" | "remaining"; + +/** A window the user pinned to the sidebar Usage item. It matches every account of the source. */ +export interface UsagePin { + sourceId: string; + windowId: string; +} + +/** Device-local. `pins` is in selection order. */ +export interface UsagePreferences { + displayAs: UsageDisplayAs; + /** null follows source defaults; an array is the user's selection, including none. */ + pins: UsagePin[] | null; + /** The host the user picked to show usage for; null until they pick one. */ + serverId: string | null; +} + +export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { + displayAs: "used", + pins: null, + serverId: null, +}; + +const pinsSchema = z.array(z.object({ sourceId: z.string(), windowId: z.string() })); + +export const UsagePreferencesSchema = z + .object({ + displayAs: z.enum(["used", "remaining"]).catch("used"), + pins: pinsSchema.nullable().optional().catch(null), + // COMPAT(usagePinSelection): added in v0.11, remove after 2027-04-02. + // Old [] meant defaults. Read it only when the new selection is absent. + pinned: pinsSchema.optional().catch([]), + serverId: z.string().nullable().catch(null), + }) + .transform(({ displayAs, pins, pinned, serverId }): UsagePreferences => { + const legacyPins = pinned && pinned.length > 0 ? pinned : null; + return { displayAs, pins: pins === undefined ? legacyPins : pins, serverId }; + }) + .catch(DEFAULT_USAGE_PREFERENCES); + +function samePin(a: UsagePin, b: UsagePin): boolean { + return a.sourceId === b.sourceId && a.windowId === b.windowId; +} + +/** The only owner of default and customized pin selection, shared by every usage surface. */ +export function effectiveUsagePins( + preferences: UsagePreferences, + reports: readonly UsageReportEntry[], +): UsagePin[] { + if (preferences.pins !== null) return preferences.pins; + const pins: UsagePin[] = []; + for (const entry of reports) { + if (entry.report.status !== "available") continue; + const withPercent = entry.report.windows.filter( + (window) => displayPercent(window, preferences.displayAs) !== null, + ); + const marked = withPercent.filter((window) => window.summary); + for (const window of marked.length > 0 ? marked : withPercent.slice(0, 1)) { + const pin = { sourceId: entry.sourceId, windowId: window.id }; + if (!pins.some((existing) => samePin(existing, pin))) pins.push(pin); + } + } + return pins; +} + +export function isUsagePinned( + preferences: UsagePreferences, + pin: UsagePin, + reports: readonly UsageReportEntry[], +): boolean { + return effectiveUsagePins(preferences, reports).some((pinned) => samePin(pinned, pin)); +} + +/** The first edit snapshots the displayed defaults before toggling one window. */ +export function toggleUsagePin({ + preferences, + pin, + reports, +}: { + preferences: UsagePreferences; + pin: UsagePin; + reports: readonly UsageReportEntry[]; +}): UsagePreferences { + const selected = effectiveUsagePins(preferences, reports); + const pins = selected.some((existing) => samePin(existing, pin)) + ? selected.filter((existing) => !samePin(existing, pin)) + : [...selected, { sourceId: pin.sourceId, windowId: pin.windowId }]; + return { ...preferences, pins }; +} + +export function setUsageDisplayAs( + preferences: UsagePreferences, + displayAs: UsageDisplayAs, +): UsagePreferences { + return { ...preferences, displayAs }; +} + +export function setUsageHost(preferences: UsagePreferences, serverId: string): UsagePreferences { + return { ...preferences, serverId }; +} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index a7a717b627..f054d6d082 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,7 +1,14 @@ +import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; import { useCallback, useMemo } from "react"; -import { 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 { useFetchQueries, useFetchQuery } from "@/data/query"; +import { useFetchQuery } from "@/data/query"; import { getHostRuntimeStore, useHostRuntimeConnectionStatuses, @@ -11,15 +18,16 @@ import { import { useSessionStore, type SessionState } from "@/stores/session-store"; import { usageCopy } from "./copy"; import { - groupUsageByHost, replaceReport, - resolveUsagePill, + resolveAgentUsageView, resolveUsageRefresh, resolveUsageView, - type UsageHostGroup, - type UsagePill, + settleReports, + type UsageHost, type UsageQueryState, + type AgentUsageView, type UsageRefresh, + upsertReport, } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; @@ -27,12 +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; -function usageReportsQueryKey(serverId: string) { - return ["usage", "reports", serverId] as const; +/** Every report list of a host: its own and each agent's. */ +function hostUsageQueryKey(serverId: string) { + return ["usage", serverId] as const; } -function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) { - return ["usage", "agent", serverId, agentId, model] as const; +function usageReportsQueryKey(serverId: string) { + return [...hostUsageQueryKey(serverId), "reports"] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string) { + return [...hostUsageQueryKey(serverId), "agent", agentId] as const; } function requireClient(serverId: string) { @@ -41,12 +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 reportQueryKey(serverId: string, reportId: string) { - return ["usage", "report", serverId, reportId] as const; +function listReports( + queryClient: QueryClient, + serverId: string, + forceRefresh = false, +): Promise { + return streamReports({ + queryClient, + queryKey: usageReportsQueryKey(serverId), + serverId, + forceRefresh, + }); } async function getReport( @@ -61,18 +106,13 @@ async function getReport( } function supportsUsage(session: SessionState | undefined): boolean { - return session?.serverInfo?.features?.usageSources === true; + return supportsUsageReports(session?.serverInfo?.features); } async function refreshReports(queryClient: QueryClient, serverId: string): Promise { await queryClient.fetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: async () => { - const reports = await listReports(serverId, true); - for (const report of reports) - queryClient.setQueryData(reportQueryKey(serverId, report.id), report); - return reports; - }, + queryFn: () => listReports(queryClient, serverId, true), staleTime: 0, }); } @@ -92,12 +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: async () => { - const reports = await listReports(serverId); - for (const report of reports) - queryClient.setQueryData(reportQueryKey(serverId, report.id), report); - return reports; - }, + queryFn: () => listReports(queryClient, serverId), enabled: isConnected && isSupported, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, @@ -105,7 +140,9 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const refresh = useCallback(() => { void refreshReports(queryClient, serverId).catch(() => undefined); }, [queryClient, serverId]); + const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; const view = resolveUsageView({ + hostLabel, isConnected, supportsUsage: isSupported, query: toQueryState(query), @@ -113,19 +150,67 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = return { view, refresh }; } -/** Usage reports for every connected host, grouped by host. */ -export function useUsageByHost(): { - groups: UsageHostGroup[]; - refresh: (serverId: string) => void; -} { +const NO_REPORTS: UsageReportEntry[] = []; + +/** + * The reports of the sidebar's usage host, which is connected and reports usage; none until they + * load, or without a host. + */ +export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { const queryClient = useQueryClient(); + const query = useFetchQuery({ + queryKey: usageReportsQueryKey(serverId ?? ""), + 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(); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); const supportedServerIds = useSessionStore( useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))), ); - const usageHosts = useMemo( + return useMemo( () => hosts.map((host) => ({ serverId: host.serverId, @@ -135,70 +220,12 @@ export function useUsageByHost(): { })), [connectionStatuses, hosts, supportedServerIds], ); - const results = useFetchQueries( - usageHosts.map((host) => ({ - queryKey: usageReportsQueryKey(host.serverId), - queryFn: () => listReports(host.serverId), - enabled: host.isConnected && host.supportsUsage, - dataShape: "list", - staleTimeMs: REPORTS_STALE_TIME_MS, - })), - ); - const groups = groupUsageByHost( - usageHosts, - new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])), - ); - const refresh = useCallback( - (serverId: string) => { - void refreshReports(queryClient, serverId).catch(() => undefined); - }, - [queryClient], - ); - return { groups, refresh }; } /** - * The usage report for the account an agent is spending. The daemon resolves the - * report ID, which is re-resolved when the model changes (a new key) and when a - * turn completes (the query is paused while the agent runs, and its data is always - * stale, so resuming refetches). The report itself is read from the shared cache. - */ -export function useAgentUsage( - serverId: string, - agentId: string, -): { pill: UsagePill | null; entry: UsageReportEntry | null } { - const isConnected = useHostRuntimeIsConnected(serverId); - const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); - const { model, isRunning } = useSessionStore( - useShallow((state) => { - const agent = state.sessions[serverId]?.agents?.get(agentId); - return { model: agent?.model ?? null, isRunning: agent?.status === "running" }; - }), - ); - const identity = useFetchQuery({ - queryKey: agentUsageQueryKey(serverId, agentId, model), - queryFn: async () => - (await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId, - enabled: isConnected && isSupported && !isRunning, - dataShape: "value", - staleTimeMs: 0, - }); - const reportId = identity.data ?? null; - const query = useFetchQuery({ - queryKey: reportQueryKey(serverId, reportId ?? "none"), - queryFn: () => getReport(serverId, reportId!), - enabled: isConnected && isSupported && reportId !== null, - dataShape: "value", - staleTimeMs: REPORTS_STALE_TIME_MS, - }); - const entry = isSupported ? (query.data ?? null) : null; - return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry }; -} - -/** - * Forces the source to fetch one report, and only that report. The result replaces - * the report wherever it is cached — its own entry and its host's list — so every - * surface showing it moves together; until then the previous report stays on screen. + * Forces the source to fetch one report, and only that report. The result replaces the report + * in every list of its host that holds it, the host's and each agent's, so every surface showing + * it moves together; until then the previous report stays on screen. */ export function useReportRefresh( serverId: string, @@ -208,9 +235,9 @@ export function useReportRefresh( const mutation = useMutation({ mutationFn: () => getReport(serverId, reportId, true), onSuccess: (report) => { - queryClient.setQueryData(reportQueryKey(serverId, reportId), 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/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx new file mode 100644 index 0000000000..bf6dc787b2 --- /dev/null +++ b/packages/app/src/usage/refresh-button.tsx @@ -0,0 +1,31 @@ +import { RotateCw } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { usageCopy } from "./copy"; + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +/** Refreshes every report on the host; spins while a refresh is in flight. */ +export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { + const compact = useIsCompactFormFactor(); + const iconSize = paneContentToolbarIconSize(compact); + return ( + + {busy ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx new file mode 100644 index 0000000000..7b719bdb59 --- /dev/null +++ b/packages/app/src/usage/sidebar-item.tsx @@ -0,0 +1,349 @@ +import { router } from "expo-router"; +import { Fragment, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { usePanelStore } from "@/stores/panel-store"; +import { buildUsageRoute } from "@/utils/host-routes"; +import { useHostUsageWithControls } from "./controls"; +import { useUsagePreferences, type UsageDisplay } from "./display"; +import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import { useUsageHostReports } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; +import { UsageMeter } from "./meter"; +import { + choosePinnedUsageLayout, + METER_GAP, + MIN_METER_WIDTH, + resolvePinnedUsage, + type PinnedUsageLayout, + type PinnedUsageSource, +} from "./pinned"; +import type { UsageHost } from "./model"; +import { UsageOptions } from "./options"; +import { UsageBody } from "./usage-section"; + +/** Each summary window with data on the usage host, under its source; empty while none has. */ +function useUsageSummary(): readonly PinnedUsageSource[] { + const { preferences } = useUsagePreferences(); + const reports = useUsageHostReports(useUsageHostId()); + return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]); +} + +/** Whether the sidebar Usage item has anything to show. */ +export function useHasUsageSummary(): boolean { + return useUsageSummary().length > 0; +} + +/** + * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing + * while no summary window has data, since the footer's Usage icon already opens the screen. + * Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead. + */ +export function UsageSidebarItem() { + const { display } = useUsagePreferences(); + const sources = useUsageSummary(); + if (sources.length === 0) return null; + return ; +} + +/** Opens the Usage screen, over the sidebar on compact layouts. */ +export function useOpenUsageScreen(): () => void { + const isCompact = useIsCompactFormFactor(); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + return useCallback(() => { + if (isCompact) showMobileAgent(); + router.push(buildUsageRoute()); + }, [isCompact, showMobileAgent]); +} + +function UsageEntry({ + sources, + display, +}: { + sources: readonly PinnedUsageSource[]; + display: UsageDisplay; +}) { + const { t } = useTranslation(); + const label = t(builtinSidebarNavLabelKey("usage")); + const isCompact = useIsCompactFormFactor(); + const openUsageScreen = useOpenUsageScreen(); + const [open, setOpen] = useState(false); + // The sheet mounts on first open; the summary already owns the report query. + const [sheetMounted, setSheetMounted] = useState(false); + const handlePress = useCallback(() => { + if (!isCompact) { + openUsageScreen(); + return; + } + setSheetMounted(true); + setOpen(true); + }, [isCompact, openUsageScreen]); + + const trigger = ; + if (!isCompact) return trigger; + return ( + + {trigger} + {sheetMounted ? : null} + + ); +} + +/** + * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls + * in its title row. + */ +function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) return null; + return ( + + ); +} + +function HostUsageSheet({ + title, + serverId, + hosts, + onSelectHost, + display, +}: { + title: string; + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; + display: UsageDisplay; +}) { + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + {view.kind === "unavailable" ? null : } + + + + ); +} + +function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { + const windows = sources.flatMap((source) => source.windows); + return `${label}: ${windows.map((window) => window.label).join(", ")}`; +} + +function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { + return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger; +} + +function layoutWidth(event: LayoutChangeEvent): number { + return event.nativeEvent.layout.width; +} + +/** + * One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure + * the line with labels and with percents alone; the richest layout that fits is shown, and what + * it leaves out takes no space at all. + */ +function PinnedUsageTrigger({ + label, + sources, + onPress, +}: { + label: string; + sources: readonly PinnedUsageSource[]; + onPress: () => void; +}) { + const [available, setAvailable] = useState(null); + const [labelsWidth, setLabelsWidth] = useState(null); + const [percentsWidth, setPercentsWidth] = useState(null); + const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => { + setAvailable(layoutWidth(event)); + }, []); + const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => { + setLabelsWidth(layoutWidth(event)); + }, []); + const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => { + setPercentsWidth(layoutWidth(event)); + }, []); + const layout = choosePinnedUsageLayout({ + available, + labelsWidth, + percentsWidth, + windowCount: sources.reduce((count, source) => count + source.windows.length, 0), + }); + return ( + + + + + + + + + + + + + + ); +} + +/** + * The line's items as direct children of one row, so every meter shares the leftover width + * equally. + */ +function PinnedUsageLine({ + sources, + layout, + measuring = false, +}: { + sources: readonly PinnedUsageSource[]; + layout: PinnedUsageLayout; + measuring?: boolean; +}) { + return sources.map((source, sourceIndex) => ( + + + + + {source.windows.map((window, windowIndex) => { + const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap; + return ( + + {layout === "meters" ? ( + + + + ) : null} + + {window.percentText} + {layout === "percents" || window.shortLabel === "" ? null : ( + {` ${window.shortLabel}`} + )} + + + ); + })} + + )); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: "100%", + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + // Same row geometry and leading rail as Add project and the footer icons. + minHeight: 28, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + }, + triggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + line: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + }, + // Wide enough that nothing inside wraps or shrinks; each child reports its natural width. + measure: { + position: "absolute", + top: 0, + left: 0, + width: 10_000, + opacity: 0, + }, + measured: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + }, + sourceGap: { + marginLeft: theme.spacing[3], + }, + iconGap: { + marginLeft: theme.spacing[1.5], + }, + windowGap: { + marginLeft: theme.spacing[2], + }, + // Every slot takes an equal share of the leftover width, so the line fills the row. + meterSlot: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: MIN_METER_WIDTH, + marginRight: METER_GAP, + }, + meter: { + height: 6, + borderRadius: 3, + }, + percent: { + flexShrink: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + windowLabel: { + color: theme.colors.foregroundMuted, + }, + sheetBody: { + padding: theme.spacing[3], + gap: theme.spacing[3], + }, +})); diff --git a/packages/app/src/usage/tone.ts b/packages/app/src/usage/tone.ts index 4727853c2f..d35d836f8b 100644 --- a/packages/app/src/usage/tone.ts +++ b/packages/app/src/usage/tone.ts @@ -1,6 +1,10 @@ -import type { UsageTone } from "./types"; +import { usedPercent } from "./model"; +import type { UsageTone, UsageWindow } from "./types"; -export function deriveTone(usedPct: number | null | undefined): UsageTone { +/** The source's tone for a window, or one derived from how much of it is used. */ +export function windowTone(window: UsageWindow): UsageTone { + if (window.tone) return window.tone; + const usedPct = usedPercent(window); if (usedPct == null) return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts index 2e2485b2d5..58b8e31c98 100644 --- a/packages/app/src/usage/types.ts +++ b/packages/app/src/usage/types.ts @@ -3,8 +3,9 @@ import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/mes export type { UsageReportEntry }; export type UsageReport = UsageReportEntry["report"]; export type UsageTone = ProviderUsageTone; -export type UsageWindow = UsageReport["windows"][number]; -export type UsageBalance = NonNullable[number]; +export type AvailableUsageReport = Extract; +export type UsageWindow = AvailableUsageReport["windows"][number]; +export type UsageBalance = NonNullable[number]; export type UsageBalanceUnit = UsageBalance["unit"]; /** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx index 96006f7728..d8e2ba08dc 100644 --- a/packages/app/src/usage/usage-screen.tsx +++ b/packages/app/src/usage/usage-screen.tsx @@ -1,11 +1,15 @@ -import { useCallback } from "react"; import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; +import { useMemo, type ReactNode } from "react"; +import { View } from "react-native"; import { PageLayout } from "@/components/page-layout"; +import { useHostUsageWithControls } from "./controls"; import { usageCopy } from "./copy"; -import type { UsageHostGroup } from "./model"; -import { useUsageByHost } from "./queries"; -import { UsageMessage, UsageSection } from "./usage-section"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageOptions } from "./options"; +import { UsageBody, UsageMessage } from "./usage-section"; // The screen is reachable by URL, so there may be no history to go back to. function leaveUsage(): void { @@ -18,40 +22,53 @@ function leaveUsage(): void { export function UsageScreen() { const isFocused = useIsFocused(); + if (!isFocused) return {null}; + return ; +} + +function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { return ( - - {isFocused ? : null} + + {children} ); } -function UsageScreenContent() { - const { groups, refresh } = useUsageByHost(); +function FocusedUsageScreen() { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } return ( - <> - {groups.length === 0 ? : null} - {groups.map((group) => ( - - ))} - + ); } -function HostUsageGroup({ - group, - onRefresh, +function HostUsage({ + serverId, + hosts, + onSelectHost, }: { - group: UsageHostGroup; - onRefresh: (serverId: string) => void; + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; }) { - const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]); + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); return ( - + + + {view.kind === "unavailable" ? null : } + + + ); } diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 846bc6d6f5..ffb40e985d 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -1,4 +1,3 @@ -import { RefreshCw } from "lucide-react-native"; import { useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -7,6 +6,8 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; +import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; @@ -14,48 +15,39 @@ export function UsageSection({ serverId, title, view, + display, onRefresh, testID, }: { serverId: string; title: string; view: UsageView; + display: UsageDisplay; onRefresh: () => void; testID?: string; }) { - const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - - const refreshButton = useMemo( - () => - view.kind === "unavailable" ? null : ( - - ), - [busy, onRefresh, view.kind], + const trailing = useMemo( + () => , + [onRefresh, view], ); return ( - - + + ); } -function UsageBody({ +/** What a host's usage view shows: its reports, or why there are none. */ +export function UsageBody({ serverId, view, + display, onRefresh, }: { serverId: string; view: UsageView; + display: UsageDisplay; onRefresh: () => void; }) { if (view.kind === "unavailable") { @@ -80,7 +72,7 @@ function UsageBody({ return ; } - return ; + return ; } export function UsageMessage({ text }: { text: string }) { diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 9627cb1571..5260a1c44b 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,62 +1,196 @@ +import { Pin } from "lucide-react-native"; import { useMemo } from "react"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatPct, formatResetLabel } from "./format"; -import { usedPercent } from "./model"; -import { deriveTone } from "./tone"; +import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { usageCopy } from "./copy"; +import { formatDisplayPct, formatResetLabel } from "./format"; +import { UsageMeter } from "./meter"; +import { displayPercent, usageWindowRowLabel } from "./model"; +import type { UsageDisplayAs } from "./preferences"; +import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; -function fillToneStyle(tone: UsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } +// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, +// so a hover never reads as the selection. Pinned rows do not react to hover. +function highlightStyle(pinned: boolean, hovered: boolean) { + if (pinned) return styles.highlightPinned; + return hovered ? styles.highlightHovered : styles.highlightNone; } -export function UsageWindowBar({ window }: { window: UsageWindow }) { - const usedPct = usedPercent(window); - const tone = window.tone ?? deriveTone(usedPct); - - const fillWidth = clampPct(usedPct ?? 0); - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], - [fillWidth, tone], - ); +export function UsageWindowBar({ + window, + displayAs, + pinnable, + pinned, + onTogglePin, + pinLabel, + pinTestID, +}: { + 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". */ + pinLabel: string; + pinTestID: string; +}) { + const shownPct = displayPercent(window, displayAs); + const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const trailing = isAtRisk ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() : formatResetLabel(window.resetsAt); + const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + const content = { + label: window.label, + value, + trailing, + isAtRisk, + percent: shownPct ?? 0, + tone, + pinned, + }; + + // Same padding as the pinnable row, so bars line up in both modes. + if (!pinnable) { + return ( + + + + ); + } + + // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same + // padding so toggling only changes the background. return ( - - - - {window.label} - - - {usedPct != null ? formatPct(usedPct) : "—"} - {trailing ? ( - {` · ${trailing}`} - ) : null} - + + {({ hovered }: { hovered?: boolean }) => ( + + )} + + ); +} + +function WindowRowContent({ + highlight, + label, + value, + trailing, + isAtRisk, + percent, + tone, + pinnable, + pinned, +}: { + highlight: StyleProp; + label: string; + value: string; + trailing: string | null | undefined; + isAtRisk: boolean; + percent: number; + tone: UsageTone; + pinnable: boolean; + pinned: boolean; +}) { + return ( + <> + + + + + + {label} + + + {value} + {trailing ? ( + {` · ${trailing}`} + ) : null} + + + + + {pinnable ? : null} - - - - + + ); +} + +const ThemedPin = withUnistyles(Pin); + +function UsagePinGlyph({ pinned }: { pinned: boolean }) { + const iconMapping = useMemo( + () => (theme: { colors: { foregroundMuted: string } }) => ({ + color: theme.colors.foregroundMuted, + fill: pinned ? theme.colors.foregroundMuted : "none", + }), + [pinned], + ); + return ( + + + + + + + + {pinned ? usageCopy.unpin : usageCopy.pin} + + ); } const styles = StyleSheet.create((theme) => ({ - container: { + contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + windowContent: { flex: 1, gap: 3 }, + pin: { width: 12, alignItems: "center" }, + tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, + row: { gap: 3, + // The highlight bleeds into the card padding so the label and bar stay on the card's rail. + marginHorizontal: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1.5], + // Its own stacking context, so the highlight layer paints above the card and below the text. + zIndex: 0, + }, + highlightNone: { + display: "none", + }, + highlightPinned: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + // The pinned surface at half strength: a separate layer, so the text keeps full opacity. + highlightHovered: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + opacity: theme.opacity[50], }, labelRow: { flexDirection: "row", @@ -65,7 +199,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, label: { - flexShrink: 1, + flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, @@ -82,26 +216,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.statusDanger, fontWeight: theme.fontWeight.normal, }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, - }, })); 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/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 0ab1cd7215..784a7569ff 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -12,6 +12,10 @@ export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; +export function useBottomSheetModal(): never { + throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. const sharedValue = (value: unknown) => ({ get: () => value }); diff --git a/packages/cli/package.json b/packages/cli/package.json index c113897cc6..3ae9ccb2cf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", - "@getpaseo/server": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/server": "0.11.0-beta.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", 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/agent/open.ts b/packages/cli/src/commands/agent/open.ts index 2119bd130b..2c1eab36d2 100644 --- a/packages/cli/src/commands/agent/open.ts +++ b/packages/cli/src/commands/agent/open.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; +import type { AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; import { openDesktopWithAgent } from "../open.js"; import type { CommandError, @@ -30,10 +31,21 @@ export function addOpenOptions(command: Command): Command { .option("--server ", "Server ID (defaults to the local daemon)"); } -async function resolveServerId(options: CommandOptions): Promise { +function toOpenError(err: unknown): CommandError { + if (err && typeof err === "object" && "code" in err) { + return err as CommandError; + } + const message = err instanceof Error ? err.message : String(err); + return { code: "OPEN_FAILED", message: `Failed to open agent: ${message}` }; +} + +async function resolveAgentTarget( + agentId: string, + options: CommandOptions, +): Promise { const explicitServerId = typeof options.server === "string" ? options.server.trim() : ""; if (explicitServerId) { - return explicitServerId; + return { serverId: explicitServerId, agentId }; } const client = await connectToDaemon({ target: options.daemonTarget }); @@ -46,7 +58,19 @@ async function resolveServerId(options: CommandOptions): Promise { }; throw error; } - return serverId; + + const fetchResult = await client.fetchAgent({ agentId }); + if (!fetchResult) { + const error: CommandError = { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentId}`, + details: 'Use "paseo ls" to list available agents', + }; + throw error; + } + return { serverId, agentId: fetchResult.agent.id }; + } catch (err) { + throw toOpenError(err); } finally { await client.close().catch(() => {}); } @@ -66,12 +90,12 @@ export async function runOpenCommand( throw error; } - const serverId = await resolveServerId(options); - await openDesktopWithAgent({ serverId, agentId }); + const target = await resolveAgentTarget(agentId, options); + await openDesktopWithAgent(target); return { type: "single", - data: { agentId, serverId, status: "opened" }, + data: { ...target, status: "opened" }, schema: openAgentSchema, }; } 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..4250ba8620 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -218,7 +218,7 @@ export function createPluginCommand(): Command { .description("Trust and install a plugin from a directory, Git repository, or npm package") .argument( "", - "Host directory, Git or npm source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source", ) .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index d4d8a682cf..df0c13295b 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -228,7 +228,7 @@ import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; export default function contribute(client: PluginClientContext) { - client.addSurface("main", Surface); + client.addScreen({ id: "main", title: "Review", Component: Surface }); client.addWorkspacePanel({ id: "review", title: "Review", @@ -294,7 +294,9 @@ export default function contribute(client: PluginClientContext) { `, ); const check = typecheckPlugin(directory); - await expect(check).rejects.toThrow("has no exported member 'PluginComposerPillProps'"); + await expect(check).rejects.toThrow( + /has no exported member (named )?'PluginComposerPillProps'/, + ); await expect(check).rejects.toThrow("Property 'button' is missing"); await expect(check).rejects.toThrow("has no call signatures"); }, 20_000); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 2046b0cd8e..448aa956fa 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -20,17 +20,23 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { GreetingSurface } from "./client/greeting"; +const CLIENT_ENTRY = `import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { GreetingScreen } from "./client/greeting"; + +function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "greeting" })} + /> + ); +} export default function contribute(client: PluginClientContext) { - client.addSurface("greeting", GreetingSurface); - client.addSidebarItem({ - id: "greeting", - title: "Greeting", - icon: "MessageCircle", - surface: "greeting", - }); + client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen }); + client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem }); return () => {}; } `; @@ -63,7 +69,7 @@ export function createGreeting({ name }: RpcInput) { } `; -const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +const CLIENT_GREETING = `import type { PluginScreenProps } from "@getpaseo/plugin/client"; import { useRpc } from "@getpaseo/plugin/client"; import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -71,7 +77,7 @@ import { Pressable, Text, View } from "react-native"; import { greetingRpc } from "../shared/greeting"; import { openExternal } from "./web"; -export function GreetingSurface({ theme, layout }: PluginSurfaceProps) { +export function GreetingScreen({ theme, layout }: PluginScreenProps) { const createGreeting = useRpc(greetingRpc); const greeting = useMutation({ mutationFn: createGreeting }); const styles = useMemo( 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/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index f38caa5601..d3ae10e19c 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -21,6 +21,7 @@ import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; const pollIntervalMs = 100; +const daemonReadyTimeoutMs = 120_000; const testEnv = { PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0", PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0", @@ -135,7 +136,7 @@ import('node:fs').then(({appendFileSync}) => { supervisor = await readDaemonInstance(paseoHome); return supervisor?.pid === supervisorProcess?.pid && Boolean(supervisor?.listen); }, - 120000, + daemonReadyTimeoutMs, "daemon did not publish its bound endpoint in time", ); assert(supervisor?.listen, "owned supervisor should publish its bound endpoint"); @@ -176,7 +177,8 @@ import('node:fs').then(({appendFileSync}) => { "restart request should be acknowledged", ); - const deadline = Date.now() + 20000; + // A restart includes worker startup and client reconnection, as initial readiness does. + const deadline = Date.now() + daemonReadyTimeoutMs; let statusAfterRestart = statusBeforeRestart; await waitFor( async () => { @@ -193,7 +195,7 @@ import('node:fs').then(({appendFileSync}) => { throw error; } }, - 20000, + daemonReadyTimeoutMs, "worker pid did not change after restart request", ); assert.notStrictEqual( 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/cli/tests/42-agent-open.test.ts b/packages/cli/tests/42-agent-open.test.ts new file mode 100644 index 0000000000..d468dfe16d --- /dev/null +++ b/packages/cli/tests/42-agent-open.test.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Agent Open Command Tests ===\n"); + +if (process.platform !== "linux") { + console.log("Skipped: the fake Desktop app is a Linux AppImage path."); + process.exit(0); +} + +const daemon = await startTestDaemon(); + +// runPaseoCli sets HOME to the daemon's home, where the CLI looks for +// ~/Applications/Paseo.AppImage on Linux. The fake app records its launch. +const launchRecord = join(daemon.paseoHome, "desktop-launches.txt"); +const fakeDesktop = join(daemon.paseoHome, "Applications", "Paseo.AppImage"); +await mkdir(join(daemon.paseoHome, "Applications"), { recursive: true }); +await writeFile(fakeDesktop, `#!/bin/sh\necho "$@" >> "${launchRecord}"\n`); +await chmod(fakeDesktop, 0o755); + +try { + { + console.log("Test 1: an unknown agent ID fails without opening Desktop"); + const result = await runPaseoCli(daemon, [ + "agent", + "open", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + + assert.notStrictEqual(result.exitCode, 0, "open should fail for an unknown agent"); + assert.match(JSON.parse(result.stderr).error.message, /Agent not found: does-not-exist/); + // Give a wrongly spawned detached app time to write its record. + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok( + !existsSync(launchRecord), + `Desktop should not open, but was launched with: ${existsSync(launchRecord) ? await readFile(launchRecord, "utf8") : ""}`, + ); + console.log("✓ unknown agent ID fails without opening Desktop\n"); + } +} finally { + await daemon.stop(); +} + +console.log("=== All agent open tests passed ==="); diff --git a/packages/client/package.json b/packages/client/package.json index 44f3780b56..47fd164b08 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo client SDK package", "files": [ "dist", @@ -31,7 +31,7 @@ }, "scripts": { "clean": "node ../../scripts/clean-package-dist.mjs", - "build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false", + "build": "npm run build --workspace=@getpaseo/protocol && npm run build --workspace=@getpaseo/relay && tsc -p tsconfig.json --incremental false", "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.10.0", - "@getpaseo/relay": "0.10.0", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/relay": "0.11.0-beta.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index dbe3ceef64..d7ab645241 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,7 +1,9 @@ +import { readFileSync } from "node:fs"; import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, + supportsUsageReports, type DaemonClientTrace, type CreateAgentRequestOptions, type DaemonTransport, @@ -6572,6 +6574,85 @@ test("sends provider.usage.list.request and resolves provider.usage.list.respons }); }); +test.each([ + { + status: "available", + report: { + status: "available", + windows: [], + balances: undefined, + details: undefined, + planLabel: undefined, + }, + }, + { status: "error", report: { status: "error", error: "" } }, + { + status: "unavailable", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "" } }, + }, +] as const)( + "maps released-host $status usage and filters report IDs", + async ({ status, report }) => { + 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: { providerUsageList: true } }); + await connected; + const result = client.listUsageReports({ + requestId: "legacy-usage", + reportIds: ["claude"], + forceRefresh: true, + }); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "provider.usage.list.request", + requestId: "legacy-usage", + }); + const provider = { + providerId: "claude", + displayName: "Claude", + status, + windows: [], + planLabel: null, + fetchedAt: null, + error: null, + }; + mock.triggerMessage( + wrapSessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "legacy-usage", + fetchedAt: "2026-09-30T00:00:00.000Z", + providers: [provider, { ...provider, providerId: "codex" }], + }, + }), + ); + expect(await result).toStrictEqual({ + requestId: "legacy-usage", + reports: [ + { + id: "claude", + sourceId: "claude", + sourceLabel: "Claude", + icon: readFileSync( + new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url), + "utf8", + ), + account: {}, + fetchedAt: "2026-09-30T00:00:00.000Z", + report, + }, + ], + }); + }, +); + test("sends close_items_request and resolves close_items_response", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7100,3 +7181,147 @@ test("reviewed plugin updates gate before requests and preserve exact proposal d ]); } }); + +test.each([ + [undefined, false], + [{}, false], + [{ usageSources: false, providerUsageList: false }, false], + [{ usageSources: true }, true], + [{ providerUsageList: true }, true], + [{ usageSources: true, providerUsageList: true }, true], +] as const)("usage support for features %j is %s", (features, expected) => { + expect(supportsUsageReports(features)).toBe(expected); +}); + +test("uses modern usage RPC when both capabilities are advertised", async () => { + 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, providerUsageList: true } }); + await connected; + 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", + reportIds: ["claude:work"], + forceRefresh: true, + }); + const payload = { + requestId: "modern-usage", + reports: [ + { + id: "claude:work", + sourceId: "claude", + sourceLabel: "Claude", + account: { label: "Work" }, + fetchedAt: "2026-09-30T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + ], + }; + 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 () => { + 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: {} }); + await connected; + 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 2fe706c8ae..858c61e5b3 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; import { @@ -94,8 +95,7 @@ import type { RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, - UsageListReportsResponseMessage, - AgentResolveUsageReportResponseMessage, + UsageReportEntry, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -562,8 +562,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload" type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; -type UsageListReportsPayload = UsageListReportsResponseMessage["payload"]; -type AgentResolveUsageReportPayload = AgentResolveUsageReportResponseMessage["payload"]; +interface UsageListReportsPayload { + requestId: string; + reports: UsageReportEntry[]; +} type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -1188,6 +1190,11 @@ interface PingProbe { drivesLivenessFailure: boolean; } +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean { + return features?.usageSources === true || features?.providerUsageList === true; +} + export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ active: (message) => this.owned.owns(message), @@ -5255,32 +5262,105 @@ export class DaemonClient { }); } - async listUsageReports(options?: { - requestId?: string; - forceRefresh?: boolean; - reportIds?: string[]; - }): Promise { - return this.sendNamespacedCorrelatedSessionRequest({ - requestId: options?.requestId, - message: { - type: "usage.list_reports.request", - forceRefresh: options?.forceRefresh, - reportIds: options?.reportIds, - }, - }); - } - - async resolveAgentUsageReport(options: { - agentId: string; - requestId?: string; - }): Promise { - return this.sendNamespacedCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "agent.resolve_usage_report.request", - agentId: options.agentId, - }, + 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 { + requestId: payload.requestId, + reports: payload.providers + .filter( + (provider) => !options?.reportIds || options.reportIds.includes(provider.providerId), + ) + .map((provider) => { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + // 0.10 reports have no typed problems; preserve their unavailable badge and error text. + let report: UsageListReportsPayload["reports"][number]["report"]; + if (provider.status === "available") { + report = { + status: "available", + windows: provider.windows, + balances: provider.balances ?? undefined, + details: provider.details ?? undefined, + planLabel: provider.planLabel ?? undefined, + }; + } else if (provider.status === "error") { + report = { status: "error", error: provider.error ?? "" }; + } else { + report = { + status: "unavailable", + problem: { kind: "no_quota", detail: provider.error ?? "" }, + }; + } + return { + id: provider.providerId, + sourceId: provider.providerId, + sourceLabel: provider.displayName, + icon: legacyUsageIcon(provider.providerId), + account: {}, + fetchedAt: provider.fetchedAt ?? payload.fetchedAt, + report, + }; + }), + }; + } + 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/client/src/legacy-usage-icons.ts b/packages/client/src/legacy-usage-icons.ts new file mode 100644 index 0000000000..227bde8134 --- /dev/null +++ b/packages/client/src/legacy-usage-icons.ts @@ -0,0 +1,21 @@ +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +// Released hosts omit source icons. Delete these copies with the list conversion. +const ICONS: Readonly> = { + claude: + '\n', + codex: + '\n', + copilot: + '\n', + cursor: + '\n \n\n', + zai: 'Z.ai\n', + grok: '\n', + kimi: '\n \n\n', + minimax: + '\n', +}; + +export function legacyUsageIcon(sourceId: string): string | undefined { + return Object.hasOwn(ICONS, sourceId) ? ICONS[sourceId] : undefined; +} diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 385c99fa09..b1b299494a 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -6,6 +6,8 @@ const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); const { chromium } = require("playwright"); const { extractFile } = require("@electron/asar"); +const { WebSocket } = require("ws"); +const assert = require("node:assert/strict"); const EXECUTABLE_NAME = "Paseo"; const SMOKE_TIMEOUT_MS = 60_000; @@ -31,6 +33,29 @@ function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } +async function assertBuiltinPluginsStarted(listen) { + const { DaemonClient } = await import("@getpaseo/client/internal/daemon-client"); + const { builtinPlugins } = + await import("../../server/dist/server/server/plugins/builtin/index.js"); + const client = new DaemonClient({ + url: `ws://${listen}/ws`, + clientId: "packaged-builtin-smoke", + webSocketFactory: (url, options) => + new WebSocket(url, options?.protocols, { headers: options?.headers }), + }); + try { + await client.connect(); + const catalog = await client.getPluginCatalog(); + assert.deepEqual( + catalog.map(({ id }) => id).sort(), + [...builtinPlugins].sort(), + "Every built-in plugin must start in the packaged desktop daemon", + ); + } finally { + await client.close(); + } +} + function assertExecutable(filePath, label) { if (!fs.existsSync(filePath)) { throw new Error(`${label} does not exist: ${filePath}`); @@ -915,6 +940,8 @@ async function smokePackagedDesktopApp({ deadline, }); console.log("Packaged desktop smoke: renderer-started desktop daemon reported running"); + await assertBuiltinPluginsStarted(listen); + console.log("Packaged desktop smoke: every built-in plugin started"); await smokeCliShim({ appPath, env }); await smokeCliTerminal({ appPath, env }); if (expectedSandbox !== undefined) { diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index fb01c7b194..6ab5a9b691 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -17,6 +17,7 @@ files: - "!node_modules/@getpaseo/**/*.test.*" - "!node_modules/@getpaseo/**/*.spec.*" - "!node_modules/@getpaseo/server/dist/server/web-ui/**" + - "!node_modules/@getpaseo/server/dist/server/builtin-plugins/**" # Workspace packages are symlinked, so anything a dev generates inside them # lands in the asar. CI never has these; a local .debug run adds gigabytes. - "!node_modules/@getpaseo/*/.debug/**" @@ -27,6 +28,9 @@ asarUnpack: - dist/daemon/node-entrypoint-runner.js - node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/* extraResources: + # Preserve physical compiler inputs, including .d.ts files omitted from packaged node_modules. + - from: ../server/dist/server/builtin-plugins + to: builtin-plugins - from: ../app/dist to: app-dist - from: assets/editor-targets diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9436a826df..a756b08ce7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.10.0", + "version": "0.11.0-beta.3", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/desktop/src/features/browser-capture.test.ts b/packages/desktop/src/features/browser-capture.test.ts index 5c7d16a77a..ea377f51c5 100644 --- a/packages/desktop/src/features/browser-capture.test.ts +++ b/packages/desktop/src/features/browser-capture.test.ts @@ -29,7 +29,7 @@ function harness(guest: BrowserCaptureGuest | null = null) { describe("browser capture service", () => { it("validates and rounds guest-relative bounds before capture", async () => { const capturePage = vi.fn(async () => image()); - const { service } = harness({ isDestroyed: () => false, capturePage }); + const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage }); await expect( service.capture({ @@ -41,9 +41,25 @@ describe("browser capture service", () => { expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 }); }); + it("captures the selected element when the page is zoomed", async () => { + const capturePage = vi.fn(async () => image()); + const { service } = harness({ + isDestroyed: () => false, + getZoomFactor: () => 1.2, + capturePage, + }); + + await service.capture({ + browserId: "browser-1", + hostWebContentsId: 42, + rect: { x: 300, y: 200, width: 120, height: 60 }, + }); + expect(capturePage).toHaveBeenCalledWith({ x: 360, y: 240, width: 144, height: 72 }); + }); + it("rejects invalid or unavailable captures without touching the guest", async () => { const capturePage = vi.fn(async () => image()); - const { service } = harness({ isDestroyed: () => false, capturePage }); + const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage }); await expect( service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }), diff --git a/packages/desktop/src/features/browser-capture.ts b/packages/desktop/src/features/browser-capture.ts index c2c7326ce7..199fde27e7 100644 --- a/packages/desktop/src/features/browser-capture.ts +++ b/packages/desktop/src/features/browser-capture.ts @@ -5,6 +5,7 @@ export interface BrowserCaptureImage { export interface BrowserCaptureGuest { isDestroyed(): boolean; + getZoomFactor(): number; capturePage(rect: BrowserCaptureRect): Promise; } @@ -40,7 +41,9 @@ export interface BrowserCaptureService { copy(payload: unknown): Promise; } -function captureRect(value: unknown): BrowserCaptureRect | null { +// The renderer measures the element in page CSS pixels, while capturePage +// takes device-independent pixels, so the page zoom scales the region. +function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | null { if (!value || typeof value !== "object") return null; const record = value as Record; const coordinates = [record.x, record.y, record.width, record.height]; @@ -51,7 +54,9 @@ function captureRect(value: unknown): BrowserCaptureRect | null { ) { return null; } - const [x, y, width, height] = coordinates as number[]; + const [x, y, width, height] = (coordinates as number[]).map( + (coordinate) => coordinate * zoomFactor, + ); if (width <= 0 || height <= 0) return null; return { x: Math.max(0, Math.round(x)), @@ -80,8 +85,9 @@ export function createBrowserCaptureService( async capture({ browserId, hostWebContentsId, rect }) { if (typeof browserId !== "string" || browserId.trim().length === 0) return null; const guest = dependencies.findGuest(browserId, hostWebContentsId); - const bounds = captureRect(rect); - if (!guest || guest.isDestroyed() || !bounds) return null; + if (!guest || guest.isDestroyed()) return null; + const bounds = captureRect(rect, guest.getZoomFactor()); + if (!bounds) return null; try { const image = await guest.capturePage(bounds); return image.isEmpty() ? null : image.toDataURL(); diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 259bb95330..41f4d1f0e9 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index d94d404079..99624f726f 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.10.0", + "version": "0.11.0-beta.3", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 19cf0cb075..efb069a90a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -70,8 +70,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -81,8 +81,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.10.0", - "@getpaseo/protocol": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index e1605e20f6..5bc82d8a58 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -47,8 +47,46 @@ interface PluginNavigableHostProps extends PluginHostProps { }; } +/** String keys and values: params travel in the screen's URL. */ +export type PluginScreenParams = Record; + +export interface PluginOpenScreenInput { + screenId: string; + params?: PluginScreenParams; +} + +export interface PluginScreenLocation { + screenId: string; + params: PluginScreenParams; +} + export interface PluginSurfaceProps extends PluginNavigableHostProps {} +export interface PluginScreenProps extends PluginSurfaceProps { + /** The params the screen was opened with; `{}` when none. */ + params: PluginScreenParams; +} + +export interface PluginPopoverProps extends PluginHostProps { + close(): void; + openScreen(input: PluginOpenScreenInput): void; +} + +export interface PluginSidebarItemProps extends PluginHostProps { + /** This plugin's screen open on this item's host, with its params, else null. */ + currentScreen: PluginScreenLocation | null; + openScreen(input: PluginOpenScreenInput): void; + /** Anchored to the item on wide layouts; a bottom sheet on compact layouts. */ + openPopover(Content: ComponentType): void; +} + +export interface PluginSidebarItemContribution { + id: string; + /** Settings row label, accessibility label, and default label for SidebarRow. */ + title: string; + Component: ComponentType; +} + export interface PluginIconProps { name: string; size?: number; @@ -84,10 +122,21 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { agentId?: string; } -export interface PluginClientContext extends PluginCommandCapabilities { - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +interface PluginClientContextAliases { + /** @deprecated Use `addScreen`. */ addSurface(id: string, Component: ComponentType): PluginCleanup; + /** @deprecated Use `addSidebarHeaderItem`. */ addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; +} + +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; + addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; @@ -123,11 +172,21 @@ export interface PluginSettingsScreenContribution { Component: ComponentType; } +/** The screen header's title: fixed, or derived from the params the screen was opened with. */ +export type PluginScreenTitle = string | ((params: PluginScreenParams) => string); + +export interface PluginScreenContribution { + id: string; + title: PluginScreenTitle; + Component: ComponentType; +} + export interface PluginSurfaceContribution { id: string; Component: ComponentType; } +/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */ export interface PluginSidebarContribution { id: string; title: string; @@ -174,6 +233,8 @@ export interface PluginCommandCapabilities { contract: PluginRpcContract, input: ZodInput, ): Promise>; + openScreen(input: PluginOpenScreenInput): void; + /** @deprecated Use `openScreen`. */ openSurface(id: string): void; openSettings(id: string): void; } diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index b6f0e342f7..810462354f 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -1,6 +1,13 @@ export type { PluginHostProps, PluginSurfaceProps, + PluginScreenProps, + PluginPopoverProps, + PluginScreenParams, + PluginOpenScreenInput, + PluginScreenLocation, + PluginSidebarItemProps, + PluginSidebarItemContribution, PluginIconProps, PluginPanelLocation, PluginOpenPanelOptions, @@ -12,6 +19,8 @@ export type { PluginWorkspacePanelContribution, PluginSettingsScreenContribution, PluginSurfaceContribution, + PluginScreenContribution, + PluginScreenTitle, PluginSidebarContribution, PluginTimelineTransformerContribution, PluginTimelineItemProps, diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 35abb0475d..6686d8292b 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -63,3 +63,24 @@ export interface ExternalLinkProps { onError?: (error: unknown) => void; } export declare const ExternalLink: ComponentType; + +export type SidebarIcon = string | ComponentType<{ size: number; color: string }>; +export interface SidebarRowProps { + /** + * Tells rows of one item apart when the item renders several: "bot-2". Unique within the item. + * Omit it when the item renders one row. + */ + id?: string; + /** A Lucide icon name or a component. */ + icon?: SidebarIcon; + /** Defaults to the item's registered title. */ + label?: string; + onPress(): void; + active?: boolean; + /** Right slot. Renders beside the row's pressable, so a button here presses on its own. */ + trailing?: ReactNode; +} +/** A sidebar navigation row. Render it from a sidebar item's `Component`. */ +export declare const SidebarRow: ComponentType; +/** A line between groups of rows. Render it from a sidebar item's `Component`. */ +export declare const SidebarSeparator: ComponentType; diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 5589cab825..a71f2ca696 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -40,6 +40,7 @@ import { type ProviderConnection, type ProviderEvent, type ProviderInput, + type ProviderLaunch, type ProviderPermissionResponse, type ProviderPersistence, type ProviderSessionConfig, @@ -69,6 +70,7 @@ export async function createAcpProviderConnection( } const probe = await AcpRuntime.start({ options, + launch: request.launch, boundarySessionId: "capability-probe", env: {}, emit: () => undefined, @@ -97,6 +99,7 @@ export async function createAcpProviderConnection( }; const state: AcpConnectionState = { options, + launch: request.launch, capabilities, sessions, emit, @@ -141,6 +144,7 @@ export async function createAcpProviderConnection( } interface AcpConnectionState { + launch?: ProviderLaunch; options: RunAcpProviderOptions; capabilities: readonly ProviderCapability[]; sessions: Map; @@ -245,6 +249,7 @@ async function discover( ): Promise { const runtime = await AcpRuntime.start({ options: state.options, + launch: state.launch, boundarySessionId: "catalog", env: {}, emit: state.emit, @@ -269,6 +274,7 @@ async function listSessions( ): Promise { const runtime = await AcpRuntime.start({ options: state.options, + launch: state.launch, boundarySessionId: "sessions", env: {}, emit: state.emit, @@ -299,6 +305,7 @@ async function openSession( throw new Error(`Session already exists: ${input.sessionId}`); const runtime = await AcpRuntime.start({ options: state.options, + launch: state.launch, boundarySessionId: input.sessionId, env: input.config.env, emit: state.emit, @@ -336,6 +343,7 @@ function requireSession(state: AcpConnectionState, sessionId: string): AcpBounda } interface StartRuntimeOptions { + launch?: ProviderLaunch; options: RunAcpProviderOptions; boundarySessionId: string; env: Readonly>; @@ -385,9 +393,15 @@ class AcpRuntime { let stream: Stream; let closeConnector = async () => {}; if (options.options.command) { - const [executable, ...args] = options.options.command; - child = spawn(executable, args, { - env: { ...process.env, ...options.env }, + // COMPAT(pluginProviderLaunch): added in v0.10.0, remove after 2027-03-29 once plugin host floor >= v0.10.0. + // Standalone callers and older hosts do not send a daemon-resolved launch. + const launch = options.launch ?? { + command: options.options.command[0], + args: options.options.command.slice(1), + env: process.env, + }; + child = spawn(launch.command, launch.args, { + env: { ...launch.env, ...options.env }, stdio: ["pipe", "pipe", "pipe"], }); spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index be52710dba..a62b2f2c48 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -225,6 +225,53 @@ lines.on("line", (line) => { });`; describe("runAcpProvider", () => { + it("spawns the daemon-resolved launch for probes, catalogues, listing, and sessions", async () => { + const executable = await fakeAcp(` +if (process.env.LAUNCH_TOKEN !== "resolved" || process.env.CLAUDECODE || process.env.PASEO_NODE_ENV) process.exit(1); +const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + let result; + if (message.method === "initialize") result = { protocolVersion: message.params.protocolVersion, agentCapabilities: { sessionCapabilities: { list: {} } } }; + else if (message.method === "session/new") result = { sessionId: "native", configOptions: [{ id: "model", name: "Model", category: "model", type: "select", currentValue: "resolved", options: [{ value: "resolved", name: process.env.SESSION_TOKEN || "probe" }] }] }; + else if (message.method === "session/list") result = { sessions: [] }; + else return; + send({ jsonrpc: "2.0", id: message.id, result }); +});`); + const registration = runAcpProvider({ + id: "launch-acp", + label: "Launch ACP", + command: ["missing-default-command"], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.list"], + launch: { command: process.execPath, args: [executable], env: { LAUNCH_TOKEN: "resolved" } }, + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + await connection.send({ type: "catalog", requestId: "catalog" }); + expect(await waitForEvent(events, (event) => event.type === "catalog")).toMatchObject({ + catalog: { models: [{ id: "resolved", label: "probe" }] }, + }); + await connection.send({ type: "sessions", requestId: "list" }); + expect(await waitForEvent(events, (event) => event.type === "sessions")).toMatchObject({ + sessions: [], + }); + const input = openInput(); + input.config.env = { SESSION_TOKEN: "session" }; + await connection.send(input); + expect(await waitForEvent(events, (event) => event.type === "session.config")).toMatchObject({ + config: { models: [{ id: "resolved", label: "session" }] }, + }); + } finally { + await connection.close(); + } + }); + it("rejects connection cleanly when the ACP executable does not exist", async () => { const registration = runAcpProvider({ id: "missing-acp", diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts index f737034a44..533453f15e 100644 --- a/packages/plugin/src/server/acp.ts +++ b/packages/plugin/src/server/acp.ts @@ -115,6 +115,7 @@ export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistra label: options.label, description: options.description, icon: options.icon, + command: options.command, connect(request) { return createAcpProviderConnection(options, request); }, 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/provider.ts b/packages/plugin/src/server/provider.ts index a72477d19d..33b49ab447 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -18,7 +18,6 @@ export const PROVIDER_CAPABILITIES = [ "session.revert.files", "session.subsession", "session.unarchive", - "session.usage_reference", "permission", "permission.tool_policy", "timeline.plugin", @@ -29,6 +28,9 @@ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; + /** Default executable and arguments; the daemon resolves overrides before status/connect. */ + command?: readonly [string, ...string[]]; + status?(request: ProviderStatusRequest): Promise; id: string; label: string; description?: string; @@ -38,10 +40,34 @@ export interface ProviderRegistration { } export type ProviderCatalogOptions = - | { scope: "global"; force?: boolean } - | { scope: "workspace"; cwd: string; force?: boolean }; + | { scope: "global"; force?: boolean; launch?: ProviderLaunch } + | { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch }; + +export const ProviderLaunchSchema = z + .object({ + /** Resolved executable path. */ + command: z.string().min(1), + args: z.array(z.string()), + /** Complete daemon-owned environment, including overrides and parent-session stripping. */ + env: z.record(z.string(), z.string()), + }) + .strip(); + +export type ProviderLaunch = z.infer; + +export interface ProviderStatusRequest { + launch?: ProviderLaunch; +} + +export const ProviderStatusSchema = z.discriminatedUnion("available", [ + z.object({ available: z.literal(true), diagnostic: z.string().optional() }).strip(), + z.object({ available: z.literal(false), diagnostic: z.string().min(1) }).strip(), +]); + +export type ProviderStatus = z.infer; export interface ProviderConnectRequest { + launch?: ProviderLaunch; versions: readonly number[]; capabilities: readonly string[]; } @@ -88,7 +114,7 @@ export interface ProviderSessionConfig { mode?: string; thinkingOption?: string; settings: Readonly>; - providerOptions?: Readonly>; + providerOptions?: Readonly>; title?: string; persist: boolean; } @@ -228,7 +254,6 @@ export type ProviderInput = } | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } | { type: "session.interrupt"; requestId: string; sessionId: string } - | { type: "session.usage_reference"; requestId: string; sessionId: string } | { type: "session.permission"; sessionId: string; @@ -528,11 +553,6 @@ export type ProviderEvent = | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } - | { - type: "usage_reference"; - requestId: string; - reference: { source: string; input: JsonValue } | null; - } | { type: "request.failed"; requestId: string; error: ProviderError } | { type: "session.opened"; @@ -616,8 +636,6 @@ export function requiredProviderCapabilities(input: ProviderInput): readonly Pro case "session.interrupt": case "session.close": return []; - case "session.usage_reference": - return ["session.usage_reference"]; case "sessions": return ["session.list"]; case "session.open": { @@ -724,7 +742,7 @@ const sessionConfigSchema = z mode: z.string().optional(), thinkingOption: z.string().optional(), settings: jsonObjectSchema, - providerOptions: jsonObjectSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), title: z.string().optional(), persist: z.boolean(), }) @@ -896,13 +914,6 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio z .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) .strict(), - z - .object({ - type: z.literal("session.usage_reference"), - requestId: idSchema, - sessionId: idSchema, - }) - .strict(), z .object({ type: z.literal("session.permission"), @@ -1288,13 +1299,6 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio }) .strip(), z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), - z - .object({ - type: z.literal("usage_reference"), - requestId: idSchema, - reference: z.object({ source: idSchema, input: z.json() }).nullable(), - }) - .strip(), z .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) .strip(), diff --git a/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts index 0d1dd04ac5..b70e8d6d01 100644 --- a/packages/plugin/src/server/usage.test.ts +++ b/packages/plugin/src/server/usage.test.ts @@ -4,6 +4,7 @@ import { toneFromUsedPct, usedPctOf, windowFromUsedPct, + windowFromReportedDuration, } from "./usage.js"; test("shared usage tones preserve the app threshold contract", () => { @@ -17,14 +18,13 @@ test("shared usage tones preserve the app threshold contract", () => { expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]); }); -test("shared windows retain percentage, reset time, and optional headline", () => { - expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30, headline: true })).toEqual({ +test("shared windows retain percentage and reset time", () => { + expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30 })).toEqual({ id: "a", label: "A", usedPct: 30, remainingPct: 70, resetsAt: null, - headline: true, }); expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({ id: "b", @@ -85,3 +85,23 @@ describe("balanceToneFromRemaining", () => { expect(balanceToneFromRemaining(null)).toBe("default"); }); }); + +test.each([ + [18000, "five_hour", "5-hour", "5h"], + [604800, "weekly", "Weekly", "wk"], + [7200, "7200s", "2-hour", "2h"], + [86400, "86400s", "1-day", "1d"], + [90, "90s", "90-second", "90s"], + [null, "primary", "Primary limit", ""], +])( + "duration %s owns the window's identity and both names", + (durationSeconds, id, label, shortLabel) => { + expect( + windowFromReportedDuration({ + durationSeconds: durationSeconds as number | null, + unknown: { id: "primary", label: "Primary limit", shortLabel: "" }, + utilizationPct: 11, + }), + ).toMatchObject({ id, label, shortLabel, usedPct: 11 }); + }, +); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 3e34e7acf1..4ad2661d67 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,17 +1,23 @@ 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 { id: string; label: string; + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel?: string; + /** Shown in the usage summary until the user pins windows of their own. */ + summary?: boolean; usedPct?: number | null; remainingPct?: number | null; resetsAt?: string | null; runsOutAt?: string | null; shortfallPct?: number | null; tone?: "default" | "ok" | "warning" | "danger"; - headline?: boolean; } export interface UsageBalance { @@ -32,33 +38,60 @@ export interface UsageDetail { tone?: UsageWindow["tone"]; } -export interface UsageReport { - status: "available" | "unavailable" | "error"; - planLabel?: string; - windows: UsageWindow[]; - balances?: UsageBalance[]; - details?: UsageDetail[]; - error?: string; +export type UsageProblem = + | { kind: "expired"; expiresAt: string; refreshedBy?: string } + | { kind: "rejected"; status: number; refreshedBy?: string } + | { kind: "no_quota"; detail: string }; + +export type UsageReport = + | { + status: "available"; + planLabel?: string; + windows: UsageWindow[]; + balances?: UsageBalance[]; + details?: UsageDetail[]; + } + | { status: "unavailable"; problem: UsageProblem } + | { status: "error"; error: string }; + +export interface UsageAccount { + /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ + key: string; + label?: string; + /** Store locator, opaque to the daemon. */ + 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; - /** Stable account identity, resolved without fetching usage. */ - identify(input: unknown): Promise<{ key: string; label?: string } | null>; + /** 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; - discover?(): Promise; } export function windowFromUsedPct(input: { id: string; label: string; + shortLabel?: string; + summary?: boolean; utilizationPct: number | null | undefined; resetsAt?: string | null; tone?: UsageWindow["tone"]; - headline?: boolean; }): UsageWindow { const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; const window: UsageWindow = { @@ -68,11 +101,61 @@ export function windowFromUsedPct(input: { remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), resetsAt: input.resetsAt ?? null, }; + if (input.shortLabel !== undefined) window.shortLabel = input.shortLabel; + if (input.summary) window.summary = true; if (input.tone) window.tone = input.tone; - if (input.headline) window.headline = true; return window; } +/** + * Numeric provider windows have one identity and vocabulary, independent of response slots. + * Pass null when the provider omits the duration; reset countdowns are not window lengths. + * Named API fields (weekly, monthly, etc.) use windowFromUsedPct instead. + */ +export function windowFromReportedDuration(input: { + durationSeconds: number | null; + /** Stable quota identity and provider name for a model- or feature-scoped limit. */ + scope?: { id: string; label: string }; + /** Neutral identity and names when the provider does not report a positive duration. */ + unknown: { id: string; label: string; shortLabel: string }; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + summary?: boolean; + tone?: UsageWindow["tone"]; +}): UsageWindow { + const duration = input.durationSeconds; + const name = + duration !== null && Number.isFinite(duration) && duration > 0 + ? durationWindowName(duration) + : input.unknown; + const scope = input.scope; + return windowFromUsedPct({ + id: scope ? `${scope.id}:${name.id}` : name.id, + label: scope ? `${scope.label} · ${name.label}` : name.label, + shortLabel: scope + ? `${scope.label}${name.shortLabel ? ` ${name.shortLabel}` : ""}` + : name.shortLabel, + utilizationPct: input.utilizationPct, + resetsAt: input.resetsAt, + summary: input.summary, + tone: input.tone, + }); +} + +function durationWindowName(seconds: number): { id: string; label: string; shortLabel: string } { + if (seconds === 604800) return { id: "weekly", label: "Weekly", shortLabel: "wk" }; + const id = seconds === 18000 ? "five_hour" : `${seconds}s`; + const units = [ + [86400, "day", "d"], + [3600, "hour", "h"], + [60, "minute", "m"], + [1, "second", "s"], + ] as const; + const unit = units.find(([size]) => seconds % size === 0) ?? units[units.length - 1]!; + const amount = seconds / unit[0]; + return { id, label: `${amount}-${unit[1]}`, shortLabel: `${amount}${unit[2]}` }; +} + /** * The tone scale for anything measured against a known limit, windows and balances alike. * @@ -114,11 +197,6 @@ export function hashAccountKey(value: string): string { return createHash("sha256").update(value).digest("hex"); } -export function unavailableUsage(): UsageReport { - return { - status: "unavailable", - windows: [], - balances: [], - details: [], - }; +export function unavailable(problem: UsageProblem): UsageReport { + return { status: "unavailable", problem }; } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 4d07048229..eb5ad255d4 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/agent-feature-schemas.test.ts b/packages/protocol/src/agent-feature-schemas.test.ts index a14ef5f746..ae76dd507e 100644 --- a/packages/protocol/src/agent-feature-schemas.test.ts +++ b/packages/protocol/src/agent-feature-schemas.test.ts @@ -49,6 +49,22 @@ describe("agent feature schemas", () => { expect(parsed.value).toBe("flex"); }); + it.each(["icon", "label"])( + "preserves the %s desktop trigger for any select feature", + (desktopTrigger) => { + const feature = { + type: "select", + id: "custom-feature", + label: "Custom setting", + icon: "gauge", + value: "first", + options: [{ id: "first", label: "First" }], + desktopTrigger, + }; + expect(AgentFeatureSchema.parse(feature)).toEqual(feature); + }, + ); + it("rejects invalid features", () => { const invalidDiscriminator = AgentFeatureSchema.safeParse({ type: "slider", diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index ea412c16be..5c0fce9608 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -145,6 +145,8 @@ export interface AgentFeatureSelect { description?: string; tooltip?: string; icon?: string; + /** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */ + desktopTrigger?: "icon" | "label"; value: string | null; options: AgentSelectOption[]; } @@ -496,7 +498,7 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -export type ProviderOptions = Record; +export type ProviderOptions = Record; export interface McpToolRef { kind: "mcp"; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a716d45893..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, @@ -301,6 +302,7 @@ export const AgentFeatureSelectSchema = z.object({ description: z.string().optional(), tooltip: z.string().optional(), icon: z.string().optional(), + desktopTrigger: z.enum(["icon", "label"]).optional(), value: z.string().nullable(), options: z.array(AgentSelectOptionSchema), }); @@ -417,7 +419,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [ McpSseServerConfigSchema, ]); -const ProviderOptionsSchema = z.record(z.string(), z.json()); +const ProviderOptionsSchema = z.record(z.string(), z.unknown()); const McpToolRefSchema = z .object({ @@ -1450,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, @@ -1776,15 +1788,11 @@ 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(), }); -export const AgentResolveUsageReportRequestMessageSchema = z.object({ - type: z.literal("agent.resolve_usage_report.request"), - requestId: z.string(), - agentId: z.string(), -}); export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), @@ -3245,7 +3253,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProviderDiagnosticRequestMessageSchema, ProviderUsageListRequestMessageSchema, UsageListReportsRequestMessageSchema, - AgentResolveUsageReportRequestMessageSchema, ResumeAgentRequestMessageSchema, ImportAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -6194,6 +6201,13 @@ export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "er export const ProviderUsageWindowSchema = z.object({ id: z.string(), label: z.string(), + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel: z.string().optional(), + /** Shown in the usage summary until the user pins windows of their own. */ + summary: z.boolean().optional(), usedPct: z.number().nullable().optional(), remainingPct: z.number().nullable().optional(), resetsAt: z.string().nullable().optional(), @@ -6243,14 +6257,30 @@ export const ProviderUsageListResponseMessageSchema = z.object({ }), }); -export const UsageReportSchema = z.object({ - status: ProviderUsageStatusSchema, - planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema.extend({ headline: z.boolean().optional() })), - balances: z.array(ProviderUsageBalanceSchema).optional(), - details: z.array(ProviderUsageDetailSchema).optional(), - error: z.string().optional(), -}); +export const UsageProblemSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("expired"), + expiresAt: z.iso.datetime(), + refreshedBy: z.string().optional(), + }), + z.object({ + kind: z.literal("rejected"), + status: z.number().int(), + refreshedBy: z.string().optional(), + }), + z.object({ kind: z.literal("no_quota"), detail: z.string() }), +]); +export const UsageReportSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), + }), + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), +]); export const UsageReportEntrySchema = z.object({ id: z.string(), account: z.object({ label: z.string().optional() }), @@ -6260,13 +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) }), -}); -export const AgentResolveUsageReportResponseMessageSchema = z.object({ - type: z.literal("agent.resolve_usage_report.response"), - payload: z.object({ requestId: z.string(), reportId: z.string().nullable() }), + payload: z.object({ requestId: z.string(), error: z.string().nullable() }), }); const AgentSlashCommandSchema = z.object({ @@ -6954,8 +6984,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsUpdateMessageSchema, UsageListReportsResponseMessageSchema, - AgentResolveUsageReportResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -7134,12 +7164,10 @@ export type ProviderDiagnosticResponseMessage = z.infer< typeof ProviderDiagnosticResponseMessageSchema >; export type ProviderUsageTone = z.infer; +export type UsageProblem = z.infer; export type UsageReport = z.infer; export type UsageReportEntry = z.infer; export type UsageListReportsResponseMessage = z.infer; -export type AgentResolveUsageReportResponseMessage = z.infer< - typeof AgentResolveUsageReportResponseMessageSchema ->; export type ProviderUsageStatus = z.infer; export type ProviderUsage = z.infer; export type ProviderUsageWindow = z.infer; 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/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index ccf73f23fd..c94f8ca965 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -54,6 +54,8 @@ export const ProviderOverrideSchema = z.object({ description: z.string().optional(), command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string(), z.string()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. params: z.record(z.string(), z.unknown()).optional(), models: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(), @@ -82,15 +84,10 @@ export const ProviderOverridesSchema = z } const isBuiltinProvider = builtinProviderIdSet.has(providerId); - if (!isBuiltinProvider && !provider.extends) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [providerId, "extends"], - message: `Custom provider "${providerId}" must declare extends.`, - }); - } - - if (!isBuiltinProvider && !provider.label) { + // Config loads before plugin registration. An entry without extends is an override; + // registry construction decides whether its provider exists. Configurable plugin IDs + // use this existing config alphabet; dots/underscores remain registration-only. + if (!isBuiltinProvider && provider.extends && !provider.label) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [providerId, "label"], diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index 62c02bd38d..718c7e05ca 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -33,7 +33,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [ archiveOnFinish: z.boolean().optional(), isolation: z.enum(["local", "worktree"]).optional(), title: z.string().trim().min(1).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), featureValues: z.record(z.string(), z.unknown()).optional(), systemPrompt: z.string().optional(), mcpServers: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/relay/package.json b/packages/relay/package.json index 3693a832f6..8e31759e57 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", @@ -11,21 +11,15 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "import": "./src/index.ts", - "default": "./src/index.ts" + "default": "./dist/index.js" }, "./e2ee": { "types": "./dist/e2ee.d.ts", - "node": "./dist/e2ee.js", - "import": "./src/e2ee.ts", - "default": "./src/e2ee.ts" + "default": "./dist/e2ee.js" }, "./cloudflare": { "types": "./dist/cloudflare-adapter.d.ts", - "node": "./dist/cloudflare-adapter.js", - "import": "./src/cloudflare-adapter.ts", - "default": "./src/cloudflare-adapter.ts" + "default": "./dist/cloudflare-adapter.js" } }, "publishConfig": { diff --git a/packages/relay/src/package-exports.test.ts b/packages/relay/src/package-exports.test.ts new file mode 100644 index 0000000000..2e8dda9395 --- /dev/null +++ b/packages/relay/src/package-exports.test.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); +const RELAY_PACKAGE_ROOT = path.resolve(THIS_DIR, ".."); + +interface PackageManifest { + exports: Record>; +} + +function readManifest(): PackageManifest { + return JSON.parse(readFileSync(path.join(RELAY_PACKAGE_ROOT, "package.json"), "utf8")); +} + +function listPublishedFiles(): Set { + if (!existsSync(path.join(RELAY_PACKAGE_ROOT, "dist/index.js"))) { + execFileSync("npm", ["run", "build"], { cwd: RELAY_PACKAGE_ROOT, stdio: "inherit" }); + } + const output = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: RELAY_PACKAGE_ROOT, + encoding: "utf8", + }); + const [pack] = JSON.parse(output) as Array<{ files: Array<{ path: string }> }>; + return new Set(pack.files.map((file) => file.path)); +} + +function listExportTargets(manifest: PackageManifest): Array<{ label: string; file: string }> { + const targets: Array<{ label: string; file: string }> = []; + for (const [subpath, conditions] of Object.entries(manifest.exports)) { + for (const [condition, target] of Object.entries(conditions)) { + targets.push({ label: `${subpath} [${condition}] -> ${target}`, file: target.slice(2) }); + } + } + return targets; +} + +describe("published relay package", () => { + it("ships every file its exports map resolves to, for every condition", () => { + const published = listPublishedFiles(); + const missing = listExportTargets(readManifest()) + .filter((target) => !published.has(target.file)) + .map((target) => target.label); + + expect(missing).toEqual([]); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index 047c7b72a1..8218960c6a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.10.0", + "version": "0.11.0-beta.3", "description": "Paseo backend server", "files": [ "dist/server", @@ -73,7 +73,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).includes('node_modules')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.10.0", - "@getpaseo/highlight": "0.10.0", - "@getpaseo/plugin": "0.10.0", - "@getpaseo/protocol": "0.10.0", - "@getpaseo/relay": "0.10.0", + "@getpaseo/client": "0.11.0-beta.3", + "@getpaseo/highlight": "0.11.0-beta.3", + "@getpaseo/plugin": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/relay": "0.11.0-beta.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", 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/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index d7af13a3d0..75f230409f 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,12 +1,16 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; import { isWindowsCommandScript } from "../utils/windows-command.js"; import { windowsExecutableResolution } from "./windows.js"; export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js"; -type Which = (command: string, options: { all: true }) => Promise; +type Which = ( + command: string, + options: { all: true; path?: string; pathExt?: string }, +) => Promise; const require = createRequire(import.meta.url); const which = require("which") as Which; @@ -16,16 +20,20 @@ function hasPathSeparator(value: string): boolean { return value.includes("/") || value.includes("\\"); } -async function enumerateCandidates(name: string): Promise { +async function enumerateCandidates(name: string, env?: ProcessEnvRecord): Promise { if (process.platform !== "win32" && existsSync("/usr/bin/which")) { - return enumerateCandidatesViaSystemWhich(name); + return enumerateCandidatesViaSystemWhich(name, env); } - return enumerateCandidatesViaLibrary(name); + return enumerateCandidatesViaLibrary(name, env); } -async function enumerateCandidatesViaSystemWhich(name: string): Promise { +async function enumerateCandidatesViaSystemWhich( + name: string, + env?: ProcessEnvRecord, +): Promise { try { const { stdout } = await execCommand("/usr/bin/which", ["-a", name], { + baseEnv: env, timeout: 3000, killSignal: "SIGKILL", }); @@ -37,10 +45,13 @@ async function enumerateCandidatesViaSystemWhich(name: string): Promise { +async function enumerateCandidatesViaLibrary( + name: string, + env?: ProcessEnvRecord, +): Promise { let candidates: string[]; try { - candidates = await which(name, { all: true }); + candidates = await which(name, { all: true, path: env?.PATH, pathExt: env?.PATHEXT }); } catch (error) { // `which` throws ENOENT when the command is absent from PATH. if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -61,10 +72,13 @@ async function enumerateCandidatesViaLibrary(name: string): Promise { export async function probeExecutable( executablePath: string, - timeoutMs = PROBE_TIMEOUT_MS, + options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS, ): Promise { + const { probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS, env } = + typeof options === "number" ? { probeTimeoutMs: options } : options; try { await execCommand(executablePath, ["--version"], { + baseEnv: env, timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, @@ -110,10 +124,17 @@ export function executableExists( return exists(executablePath) ? executablePath : null; } +export interface ExecutableResolutionOptions { + probeTimeoutMs?: number; + env?: ProcessEnvRecord; +} + export async function findExecutable( name: string, - probeTimeoutMs = PROBE_TIMEOUT_MS, + options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS, ): Promise { + const { probeTimeoutMs = PROBE_TIMEOUT_MS, env } = + typeof options === "number" ? { probeTimeoutMs: options } : options; const trimmed = name.trim(); if (!trimmed) { return null; @@ -121,20 +142,21 @@ export async function findExecutable( if (process.platform === "win32") { return windowsExecutableResolution.find(trimmed, { - enumeratePathCandidates: enumerateCandidates, - probeExecutable, + enumeratePathCandidates: (command) => enumerateCandidates(command, env), + probeExecutable: (command, timeout) => + probeExecutable(command, { probeTimeoutMs: timeout, env }), exists: existsSync, probeTimeoutMs, }); } if (hasPathSeparator(trimmed)) { - return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null; + return (await probeExecutable(trimmed, { probeTimeoutMs, env })) ? trimmed : null; } - const candidates = await enumerateCandidates(trimmed); + const candidates = await enumerateCandidates(trimmed, env); for (const candidate of candidates) { - if (await probeExecutable(candidate, probeTimeoutMs)) { + if (await probeExecutable(candidate, { probeTimeoutMs, env })) { return candidate; } } diff --git a/packages/server/src/server/agent/agent-configuration-validator.ts b/packages/server/src/server/agent/agent-configuration-validator.ts index 1135d25e0b..0893f3a692 100644 --- a/packages/server/src/server/agent/agent-configuration-validator.ts +++ b/packages/server/src/server/agent/agent-configuration-validator.ts @@ -3,7 +3,6 @@ import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messag import type { ProviderSnapshotEntry } from "./agent-sdk-types.js"; import { filterSelectableAgentModels } from "./agent-sdk-types.js"; -import { ProviderOptionsValidationError } from "./provider-options.js"; export interface AgentConfigurationValidationInput { provider: string; @@ -16,13 +15,11 @@ export interface AgentConfigurationValidationInput { interface AgentConfigurationValidationContext { input: AgentConfigurationValidationInput; provider: ProviderSnapshotEntry; - validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined; } export function validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions, }: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] { const issues: HubExecutionAgentValidationIssue[] = []; const models = filterSelectableAgentModels(provider.models); @@ -53,17 +50,5 @@ export function validateAgentConfigurationAgainstProvider({ }); } - try { - validateOptions(input.providerOptions); - } catch (error) { - if (!(error instanceof ProviderOptionsValidationError)) throw error; - issues.push( - ...error.issues.map((issue) => ({ - path: ["providerOptions", ...issue.path], - message: issue.message, - })), - ); - } - return issues; } 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 87d3b290aa..9e809a457f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -17,7 +17,7 @@ import { PARENT_AGENT_ID_LABEL, } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; -import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; +import type { ToolPolicy } from "@getpaseo/protocol/agent-types"; import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -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; @@ -292,11 +295,6 @@ interface AgentManagerRescueTimeouts { interface ProviderEnabledFlag { enabled: boolean; derivedFromProviderId?: string | null; - validateOptions?: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions?: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy?: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -1162,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; @@ -3735,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) { @@ -3909,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 }, @@ -3921,7 +3942,7 @@ export class AgentManager { } try { - agent.currentModeId = await agent.session.getCurrentMode(); + this.applyObservedMode(agent, await agent.session.getCurrentMode()); } catch { agent.currentModeId = null; } @@ -4301,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 }; @@ -4317,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; @@ -5094,22 +5115,15 @@ export class AgentManager { private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig { const definition = this.providerDefinitions.get(config.provider); - if (config.providerOptions !== undefined && !definition?.validateOptions) { - throw new Error(`Provider '${config.provider}' does not accept providerOptions`); - } - const validatedOptions = definition?.validateOptions?.(config.providerOptions); - const withOptions = definition?.applyOptions - ? definition.applyOptions(config, validatedOptions) - : config; - this.validateToolPolicyServers(withOptions); - if (withOptions.toolPolicy && !definition?.applyToolPolicy) { + this.validateToolPolicyServers(config); + if (config.toolPolicy && !definition?.applyToolPolicy) { throw new Error( `Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`, ); } return definition?.applyToolPolicy - ? definition.applyToolPolicy(withOptions, withOptions.toolPolicy) - : withOptions; + ? definition.applyToolPolicy(config, config.toolPolicy) + : config; } private validateToolPolicyServers(config: AgentSessionConfig): void { diff --git a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts index ec9dbfdb10..a92e547edc 100644 --- a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import { createServer } from "http"; -import { mkdtempSync, rmSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import { randomUUID } from "crypto"; @@ -250,4 +250,39 @@ describe("getStructuredAgentResponse (e2e)", () => { expect(result.message.trim().toLowerCase()).toBe("hello"); }, 180000); + + test("does not run the project's Claude hooks for an internal Claude agent", async (context) => { + if (!canRunClaude) { + context.skip(); + } + const hookMarker = path.join(cwd, "stop-hook-ran"); + mkdirSync(path.join(cwd, ".claude")); + writeFileSync( + path.join(cwd, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: "command", command: `touch '${hookMarker}'` }] }], + }, + }), + ); + + const result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "claude", + model: CLAUDE_TEST_MODEL, + cwd, + title: "Branch name generator", + internal: true, + }, + prompt: + 'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}', + schema: z.object({ message: z.string() }), + maxRetries: 2, + persistSession: false, + }); + + expect(result.message.trim().toLowerCase()).toBe("hello"); + expect(existsSync(hookMarker)).toBe(false); + }, 180000); }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index d42d98aaf7..3e5e739577 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,4 +1,7 @@ import type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, AgentProviderNotice, AgentTaskItem, JsonValue, @@ -8,7 +11,13 @@ import type { import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -export type { AgentProviderNotice, AgentTaskItem }; +export type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, + AgentProviderNotice, + AgentTaskItem, +}; export type AgentProvider = string; @@ -156,29 +165,6 @@ export interface AgentCreateConfigUnattendedInput { availableModes: AgentMode[]; } -export interface AgentFeatureToggle { - type: "toggle"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: boolean; -} - -export interface AgentFeatureSelect { - type: "select"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: string | null; - options: AgentSelectOption[]; -} - -export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; - export interface AgentCapabilityFlags { [capability: string]: boolean | undefined; supportsStreaming: boolean; @@ -549,6 +535,7 @@ export interface AgentSlashCommand { } export interface ListImportableSessionsOptions { + providerOptions?: ProviderOptions; limit?: number; /** Optional case-insensitive descriptor search text. */ query?: string; @@ -660,12 +647,15 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } -export interface UsageReference { - source: string; - input: JsonValue; +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; @@ -673,8 +663,6 @@ export interface AgentSession { /** New provider-owned rows to commit on registration. streamHistory must also * replay them at their original timestamps; restored sessions omit old rows. */ readonly initialTimeline?: ImportedTimelineEntry[]; - /** Resolved at fetch time because model and credentials may change during a session. */ - getUsageReference?(): Promise; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; @@ -718,7 +706,7 @@ export interface AgentSession { } | null; } -export type FetchCatalogOptions = +export type FetchCatalogOptions = { providerOptions?: ProviderOptions } & ( | { scope: "global"; force: boolean; @@ -727,7 +715,8 @@ export type FetchCatalogOptions = scope: "workspace"; cwd: string; force: boolean; - }; + } +); export interface ProviderRefreshContext { readonly signal: AbortSignal; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index b41bc75cfc..be94a31d49 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -16,7 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), featureValues: z.record(z.string(), z.unknown()).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).nullable().optional(), + providerOptions: z.record(z.string(), z.unknown()).nullable().optional(), toolPolicy: z .object({ preapproved: z.array( 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/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8774b8784c..8b894a7765 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1,5 +1,7 @@ +import { relative } from "node:path"; import type { ProviderConnection, + ProviderLaunch, ProviderEvent, ProviderInput, ProviderRegistration, @@ -8,6 +10,10 @@ import { setImmediate as nextTurn } from "node:timers/promises"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; +import { AgentManager } from "./agent-manager.js"; +import { buildProviderRegistry } from "./provider-registry.js"; +import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; import { isStaleProviderSessionError, @@ -281,6 +287,109 @@ function expectNestedChildren(events: AgentStreamEvent[]) { } describe("PluginAgentClientRegistry", () => { + test("stores only agent options while the plugin receives merged defaults", async () => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { options: { nested: { base: true, replace: "config" } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, + providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, + }); + const own = { nested: { replace: "agent" } }; + const agent = await manager.createAgent( + { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + undefined, + { workspaceId: undefined }, + ); + expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own); + expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({ + config: { providerOptions: { nested: { base: true, replace: "agent" } } }, + }); + await agent.session?.close(); + }); + + test.each([ + { options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { + params: { ignored: true }, + options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" }, + }, + ])( + "merges configured options into plugin session.open, preserving caller input: %j", + async (override) => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { "plugin-direct": ProviderOverrideSchema.parse(override) }, + }); + const client = registry["plugin-direct"].createClient(logger); + const defaultsSession = await client.createSession({ + provider: "plugin-direct", + cwd: "/tmp", + }); + const defaultsOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(defaultsOpen.config.providerOptions).toEqual({ + nested: { base: true, replace: "config" }, + list: [1, 2], + scalar: "config", + }); + await defaultsSession.close(); + const config = { + provider: "plugin-direct", + cwd: "/tmp", + providerOptions: { nested: { replace: "agent" }, list: [3], scalar: null }, + }; + const session = await client.createSession(config); + const open = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(open.config.providerOptions).toEqual({ + nested: { base: true, replace: "agent" }, + list: [3], + scalar: null, + }); + expect(config.providerOptions).toEqual({ + nested: { replace: "agent" }, + list: [3], + scalar: null, + }); + const handle = session.describePersistence()!; + await session.close(); + const updatedRegistry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { + options: { + nested: { base: false, replace: "new-config" }, + list: [4], + scalar: "new-config", + }, + }, + }, + }); + const resumed = await updatedRegistry["plugin-direct"] + .createClient(logger) + .resumeSession(handle, config); + const resumedOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(resumedOpen.config.providerOptions).toEqual({ + nested: { base: false, replace: "agent" }, + list: [3], + scalar: null, + }); + await resumed.close(); + }, + ); + test.each([false, true])( "contains a failed session open while send is pending: %s", async (pendingSend) => { @@ -396,24 +505,6 @@ describe("PluginAgentClientRegistry", () => { await registry.shutdown(); }); - test("returns no usage reference when a plugin provider lacks the capability", async () => { - const harness = createProviderHarness(); - const registry = new PluginAgentClientRegistry(createTestLogger()); - registry.replace([harness.registration]); - const client = registry.clients()[harness.registration.id]!; - const session = await client.createSession({ - provider: harness.registration.id, - cwd: "/workspace", - }); - try { - expect(await session.getUsageReference?.()).toBeNull(); - expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false); - } finally { - await session.close(); - registry.replace([]); - } - }); - test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { const harness = createProviderHarness({ completeTurn: false }); const registry = new PluginAgentClientRegistry(createTestLogger()); @@ -703,6 +794,56 @@ async function withObservedProvider( } } +test("commits notices emitted during session open as the initial timeline with stable history timestamps", async () => { + await withObservedProvider( + { + openChildren(sessionId, emit) { + emit({ + type: "session.notice", + sessionId, + notice: { + id: "startup", + severity: "warning", + title: "Full access", + description: "Tools run without asking.", + }, + }); + emit({ + type: "session.notice", + sessionId, + notice: { id: "dismissed", severity: "info", title: "Hidden", dismissed: true }, + }); + }, + }, + async (client) => { + const session = await client.createSession({ provider: client.provider, cwd: "/workspace" }); + try { + expect(session.initialTimeline).toEqual([ + { + timestamp: expect.any(String), + item: { + type: "notification", + level: "warning", + message: "Full access\nTools run without asking.", + }, + }, + ]); + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.filter( + (event) => event.type === "timeline" && event.item.type === "notification", + ), + ).toEqual([ + { type: "timeline", provider: client.provider, ...session.initialTimeline![0] }, + ]); + } finally { + await session.close(); + } + }, + ); +}); + describe("pending provider responses", () => { test.each(["session.open", "catalog", "session.configure", "session.prompt"])( "preserves the send error for %s", @@ -835,3 +976,148 @@ describe("pending provider responses", () => { }, ); }); + +describe("plugin provider launch and status", () => { + test("resolves the executable against the provider's PATH override", async () => { + const harness = createProviderHarness(); + const registration: ProviderRegistration = { ...harness.registration, command: ["node"] }; + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([registration]); + try { + const definition = registry.definitions()[registration.id]!; + const client = definition.createClient(logger, { env: { PATH: "/paseo-nonexistent-bin" } }); + expect(await client.isAvailable()).toBe(false); + expect(await client.getDiagnostic!()).toEqual({ diagnostic: "node not found on PATH" }); + } finally { + await registry.shutdown(); + } + }); + + test("strips parent-session and daemon-control variables from session env overlays", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + try { + const client = registry.clients()[harness.registration.id]!; + await client.createSession( + { provider: harness.registration.id, cwd: "/workspace" }, + { + env: { + SESSION_TOKEN: "session", + PASEO_AGENT_ID: "agent", + CLAUDECODE: "parent", + PASEO_NODE_ENV: "development", + }, + }, + ); + const input = harness.inputs.find((value) => value.type === "session.open"); + expect(input).toMatchObject({ + config: { env: { SESSION_TOKEN: "session", PASEO_AGENT_ID: "agent" } }, + }); + if (input?.type !== "session.open") throw new Error("Expected session.open"); + expect(input.config.env).not.toHaveProperty("CLAUDECODE"); + expect(input.config.env).not.toHaveProperty("PASEO_NODE_ENV"); + } finally { + await registry.shutdown(); + } + }); + + test("resolves relative executable paths before sending launch data", async () => { + const harness = createProviderHarness(); + const launches: Array = []; + const registration: ProviderRegistration = { + ...harness.registration, + command: [relative(process.cwd(), process.execPath)], + async connect(request) { + launches.push(request.launch); + return harness.registration.connect(request); + }, + }; + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([registration]); + try { + await registry.clients()[registration.id]!.fetchCatalog({ scope: "global" }); + expect(launches).toMatchObject([{ command: process.execPath }]); + } finally { + await registry.shutdown(); + } + }); + + test("a missing executable is unavailable with a diagnostic without connecting", async () => { + const harness = createProviderHarness(); + const registration: ProviderRegistration = { + ...harness.registration, + command: ["paseo-nonexistent-provider-executable"], + }; + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([registration]); + try { + const client = registry.clients()[registration.id]!; + expect(await client.isAvailable()).toBe(false); + expect(await client.getDiagnostic!()).toEqual({ + diagnostic: "paseo-nonexistent-provider-executable not found on PATH", + }); + expect(harness.inputs).toEqual([]); + } finally { + await registry.shutdown(); + } + }); + + test.each([ + { mode: "default" as const }, + { mode: "append" as const, args: ["extra"] }, + { mode: "replace" as const, argv: [process.execPath, "replacement"] }, + ])( + "resolves $mode launch and supplies identical data to status, catalogue identity, and connect", + async (command) => { + const harness = createProviderHarness(); + const launches: Array = []; + const registration: ProviderRegistration = { + ...harness.registration, + command: [process.execPath, "default"], + async status(request) { + launches.push(request.launch); + return { available: true }; + }, + async getCatalogCacheKey(options) { + launches.push(options.launch); + return "shared"; + }, + async connect(request) { + launches.push(request.launch); + return harness.registration.connect(request); + }, + }; + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([registration]); + try { + const client = registry.definitions()[registration.id]!.createClient(logger, { + command, + env: { PLUGIN_SETTING: "custom", CLAUDECODE: "parent" }, + }); + expect(await client.isAvailable()).toBe(true); + expect(await client.getCatalogCacheKey!({ scope: "global" })).toBe("shared"); + await client.fetchCatalog({ scope: "global" }); + let expectedArgs = ["default"]; + if (command.mode === "replace") expectedArgs = ["replacement"]; + if (command.mode === "append") expectedArgs = ["default", "extra"]; + expect(launches).toHaveLength(3); + for (const launch of launches) { + expect(launch).toMatchObject({ + command: process.execPath, + args: expectedArgs, + env: { PLUGIN_SETTING: "custom" }, + }); + expect(launch!.env).not.toHaveProperty("CLAUDECODE"); + expect(launch!.env).not.toHaveProperty("PASEO_NODE_ENV"); + } + expect(launches[0]).toEqual(launches[1]); + expect(launches[1]).toEqual(launches[2]); + } finally { + await registry.shutdown(); + } + }, + ); +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index e6712d3c3b..58bddd07b6 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1,12 +1,16 @@ import { randomUUID } from "node:crypto"; +import { resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; -import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { z } from "zod"; import { PROVIDER_CAPABILITIES, PROVIDER_PROTOCOL_VERSION, ProviderEventSchema, ProviderInputSchema, + ProviderStatusSchema, + type ProviderStatus, + type ProviderLaunch, requireProviderCapabilities, type ProviderCapability, type ProviderConfigChanges, @@ -51,13 +55,14 @@ import type { ProviderRefreshContext, SteerActiveTurnOptions, SteerResult, - UsageReference, } from "./agent-sdk-types.js"; import { - isDefaultAgentCreateConfigUnattended, - resolveDefaultAgentCreateConfig, -} from "./create-agent-mode.js"; -import type { ProviderDefinition } from "./provider-registry.js"; + resolveProviderLaunch, + checkProviderLaunchAvailable, + createProviderEnv, + type ProviderRuntimeSettings, +} from "./provider-launch-config.js"; +import type { RegisteredProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js"; @@ -108,15 +113,9 @@ function providerError(error: ProviderError): Error { function isProviderRequestReply( event: ProviderEvent, -): event is Extract< - ProviderEvent, - { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } -> { +): event is Extract { return ( - event.type === "request.completed" || - event.type === "catalog" || - event.type === "sessions" || - event.type === "usage_reference" + event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" ); } @@ -143,7 +142,46 @@ class ProviderRuntime { PendingOpenDescendantState >(); - constructor(private readonly registration: ProviderRegistration) {} + constructor( + private readonly registration: ProviderRegistration, + private readonly runtimeSettings?: ProviderRuntimeSettings, + ) {} + + private async resolveLaunch(): Promise { + const command = this.registration.command; + if (!command) return undefined; + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: command[0], + }); + if (launch.source !== "override") launch.args.unshift(...command.slice(1)); + const env = createProviderEnv({ runtimeSettings: this.runtimeSettings }); + launch.env = env; + const availability = await checkProviderLaunchAvailable(launch); + if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`); + return { command: resolvePath(availability.resolvedPath), args: launch.args, env }; + } + + async status(): Promise { + try { + const launch = await this.resolveLaunch(); + if (this.registration.status) + return ProviderStatusSchema.parse(await this.registration.status({ launch })); + if (!this.registration.command) await this.getConnection(); + return { available: true }; + } catch (error) { + return { + available: false, + diagnostic: error instanceof Error ? error.message : String(error), + }; + } + } + + async catalogCacheKey(options: FetchCatalogOptions): Promise { + const callback = this.registration.getCatalogCacheKey; + if (!callback) return undefined; + return callback({ ...options, launch: await this.resolveLaunch() }); + } get negotiatedCapabilities(): readonly string[] { return this.connection?.capabilities ?? []; @@ -154,8 +192,7 @@ class ProviderRuntime { } async isAvailable(): Promise { - await this.getConnection(); - return true; + return (await this.status()).available; } async catalog( @@ -335,6 +372,7 @@ class ProviderRuntime { private async establishConnection(generation: number): Promise { const rawConnection = await this.registration.connect({ + launch: await this.resolveLaunch(), versions: [PROVIDER_PROTOCOL_VERSION], capabilities: PROVIDER_CAPABILITIES, }); @@ -526,10 +564,7 @@ class ProviderRuntime { } private finishRequest( - event: Extract< - ProviderEvent, - { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } - >, + event: Extract, ): void { const request = this.requests.get(event.requestId); if (!request) return; @@ -569,17 +604,6 @@ class ProviderRuntimeSession { return this.capabilities; } - async getUsageReference(): Promise { - if (!this.capabilities.includes("session.usage_reference")) return null; - const event = await this.runtime.complete({ - type: "session.usage_reference", - requestId: randomUUID(), - sessionId: this.providerSessionId, - }); - if (event.type !== "usage_reference") throw new Error("Invalid usage reference response"); - return event.reference; - } - onEvent(listener: (event: ProviderEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -786,7 +810,8 @@ function normalizeConnection(connection: ProviderConnection): ProviderConnection interface AdaptedPluginProvider { registration: ProviderRegistration; client: PluginAgentClient; - definition: ProviderDefinition; + definition: RegisteredProviderDefinition; + clients: Set; } /** Owns the complete plugin-provider adaptation behind the existing core provider boundary. */ @@ -807,7 +832,7 @@ export class PluginAgentClientRegistry { for (const [id, adapted] of this.providers) { if (incoming.get(id) === adapted.registration) continue; this.providers.delete(id); - void adapted.client.shutdown().catch((error) => { + void Promise.all([...adapted.clients].map((client) => client.shutdown())).catch((error) => { this.logger.warn({ err: error, provider: id }, "Failed to stop plugin provider"); }); } @@ -815,15 +840,20 @@ export class PluginAgentClientRegistry { for (const [id, registration] of incoming) { if (this.providers.has(id)) continue; const client = new PluginAgentClient(registration); - this.providers.set(id, { + const clients = new Set([client]); + const definition = createPluginProviderDefinition( registration, - client, - definition: createPluginProviderDefinition(registration, client), - }); + (_logger, runtimeSettings) => { + const configuredClient = new PluginAgentClient(registration, runtimeSettings); + clients.add(configuredClient); + return configuredClient; + }, + ); + this.providers.set(id, { registration, client, definition, clients }); } } - definitions(): Record { + definitions(): Record { return Object.fromEntries( [...this.providers].map(([id, provider]) => [id, provider.definition]), ); @@ -840,36 +870,25 @@ export class PluginAgentClientRegistry { async shutdown(): Promise { const providers = [...this.providers.values()]; this.providers.clear(); - await Promise.all(providers.map(({ client }) => client.shutdown())); + await Promise.all( + providers.flatMap(({ clients }) => [...clients].map((client) => client.shutdown())), + ); } } -const PluginProviderOptionsSchema: z.ZodType = z.record(z.string(), z.json()); - function createPluginProviderDefinition( registration: ProviderRegistration, - client: PluginAgentClient, -): ProviderDefinition { + createClient: RegisteredProviderDefinition["createClient"], +): RegisteredProviderDefinition { return { id: registration.id, - configuration: null, label: registration.label, description: registration.description ?? `Plugin provider ${registration.label}`, iconSvg: registration.icon, defaultModeId: null, modes: [], - enabled: true, - derivedFromProviderId: null, - optionsSchema: PluginProviderOptionsSchema, supportsExactMcpPreapproval: true, - validateOptions: (options) => - options === undefined ? undefined : PluginProviderOptionsSchema.parse(options), - applyOptions: (config, options) => ({ ...config, providerOptions: options }), - applyToolPolicy: (config, toolPolicy) => ({ ...config, toolPolicy }), - createClient: () => client, - resolveCreateConfig: resolveDefaultAgentCreateConfig, - isCreateConfigUnattended: isDefaultAgentCreateConfigUnattended, - fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context), + createClient, }; } @@ -886,10 +905,11 @@ class PluginAgentClient implements AgentClient { readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"]; - constructor(registration: ProviderRegistration) { - this.getCatalogCacheKey = registration.getCatalogCacheKey?.bind(registration); + constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) { this.provider = registration.id; - this.runtime = new ProviderRuntime(registration); + this.runtime = new ProviderRuntime(registration, runtimeSettings); + if (registration.getCatalogCacheKey) + this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options); this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); } @@ -955,6 +975,11 @@ class PluginAgentClient implements AgentClient { return await this.runtime.isAvailable(); } + async getDiagnostic(): Promise<{ diagnostic: string }> { + const status = await this.runtime.status(); + return { diagnostic: status.diagnostic ?? "Provider is available" }; + } + async listImportableSessions( options: ListImportableSessionsOptions = {}, ): Promise { @@ -1065,6 +1090,7 @@ class PluginAgentClient implements AgentClient { } class PluginAgentSession implements AgentSession { + readonly initialTimeline: ImportedProviderSession["timeline"]; private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); private readonly history: AgentStreamEvent[] = []; private readonly pendingPermissions = new Map(); @@ -1085,6 +1111,11 @@ class PluginAgentSession implements AgentSession { ) { this.subagentIdsBySession.set(bridge.id, null); for (const event of bridge.history) this.accept(event, false); + // Opening notices arrive before the manager subscribes to the session. + // Commit them on registration and replay the same timestamps from history. + this.initialTimeline = this.timelineHistory().filter( + (entry) => entry.item.type === "notification", + ); this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); } @@ -1096,10 +1127,6 @@ class PluginAgentSession implements AgentSession { return agentCapabilities(this.bridge.negotiatedCapabilities); } - getUsageReference(): Promise { - return this.bridge.getUsageReference(); - } - get features(): AgentFeature[] { return this.bridge.config.settings.map((setting) => setting.type === "toggle" @@ -1469,6 +1496,7 @@ class PluginAgentSession implements AgentSession { { type: "timeline", provider: this.provider, + timestamp: new Date().toISOString(), item: { type: "notification", level: event.notice.severity, @@ -1591,7 +1619,7 @@ function mapSessionConfig( ): ProviderSessionConfig { return { cwd: config.cwd, - env: { ...launchContext?.env }, + env: createProviderEnv({ baseEnv: {}, overlays: [launchContext?.env] }), systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt), mcpServers: { ...config.mcpServers }, toolPolicy: config.toolPolicy @@ -1601,9 +1629,7 @@ function mapSessionConfig( mode: config.modeId, thinkingOption: config.thinkingOptionId, settings: toJsonObject(config.featureValues ?? {}, "provider settings"), - providerOptions: config.providerOptions - ? toJsonObject(config.providerOptions, "provider options") - : undefined, + providerOptions: config.providerOptions, title: config.title ?? undefined, persist, }; diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index fddd83b626..29554fd2b2 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -43,6 +43,7 @@ export interface ResolvedProviderLaunch { command: string; args: string[]; source: ProviderLaunchSource; + env?: ProcessEnvRecord; } export interface ProviderLaunchAvailability { @@ -64,8 +65,8 @@ function normalizeLaunchDefault( return defaultBinary; } -async function resolveLaunchPath(command: string): Promise { - const found = await findExecutable(command); +async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promise { + const found = await findExecutable(command, { env }); if (found) { return found; } @@ -120,7 +121,7 @@ export async function checkProviderLaunchAvailable( const resolvedPath = defaultBinary && launch.source !== "override" ? await resolveDefaultLaunchPath(defaultBinary) - : await resolveLaunchPath(launch.command); + : await resolveLaunchPath(launch.command, launch.env); return { available: resolvedPath !== null, resolvedPath, diff --git a/packages/server/src/server/agent/provider-options.test.ts b/packages/server/src/server/agent/provider-options.test.ts index 9b642d8b5e..f5b97d064a 100644 --- a/packages/server/src/server/agent/provider-options.test.ts +++ b/packages/server/src/server/agent/provider-options.test.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentManager } from "./agent-manager.js"; import { validateProviderOptions } from "./provider-options.js"; import { applyClaudeToolPolicy, ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; import { applyCodexToolPolicy, CodexProviderOptionsSchema } from "./providers/codex/options.js"; @@ -199,3 +203,66 @@ describe("exact MCP preapproval mappings", () => { ]); }); }); + +describe("configured provider options", () => { + test.each(["options", "params"] as const)( + "Pi imports read sessionDir from config %s", + async (key) => { + const dir = mkdtempSync(join(tmpdir(), "provider-options-import-")); + try { + const file = join(dir, "native.jsonl"); + writeFileSync( + file, + JSON.stringify({ + type: "session", + version: 3, + id: "native", + cwd: "/tmp/options-project", + }) + "\n", + ); + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { pi: { [key]: { sessionDir: dir } } }, + }); + const sessions = await registry.pi.createClient(logger).listImportableSessions!({ + cwd: "/tmp/options-project", + }); + expect(sessions.map((session) => session.providerHandleId)).toEqual([file]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + test("a provider without option handling ignores the record", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + isDev: true, + providerOverrides: { mock: { options: { arbitrary: { base: true } } } }, + }); + const session = await registry.mock + .createClient(logger) + .createSession({ provider: "mock", cwd: "/tmp", providerOptions: { more: [1] } }); + expect(session.provider).toBe("mock"); + await session.close(); + }); + + test("config options reach Claude and provider validation reaches the create caller", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { options: { sandbox: { network: { allowLocalBinding: "yes" } } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { claude: registry.claude.createClient(logger) }, + providerDefinitions: { claude: registry.claude }, + }); + await expect( + manager.createAgent({ provider: "claude", cwd: "/tmp", model: "sonnet" }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("providerOptions.sandbox.network.allowLocalBinding"); + }); +}); diff --git a/packages/server/src/server/agent/provider-options.ts b/packages/server/src/server/agent/provider-options.ts index bf02410e22..8a7d0b16af 100644 --- a/packages/server/src/server/agent/provider-options.ts +++ b/packages/server/src/server/agent/provider-options.ts @@ -36,11 +36,11 @@ export class ToolPolicyUnsupportedError extends Error { } } -export function validateProviderOptions( +export function validateProviderOptions( provider: string, - schema: z.ZodType, + schema: z.ZodType, options: ProviderOptions | undefined, -): ProviderOptions | undefined { +): T | undefined { if (options === undefined) return undefined; const parsed = schema.safeParse(options); if (parsed.success) return parsed.data; @@ -75,3 +75,31 @@ function formatProviderOptionPath(path: Array): string { : `${formatted}[${JSON.stringify(segment)}]`; }, "providerOptions"); } + +/** Plain objects merge recursively; all other values replace the base value. */ +export function mergeProviderOptions( + base: ProviderOptions | undefined, + override: ProviderOptions | undefined, +): ProviderOptions | undefined { + if (base === undefined && override === undefined) return undefined; + const merged: ProviderOptions = { ...base }; + for (const [key, value] of Object.entries(override ?? {})) { + const previous = merged[key]; + Object.defineProperty(merged, key, { + value: + isPlainObject(previous) && isPlainObject(value) + ? mergeProviderOptions(previous, value) + : value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return merged; +} + +function isPlainObject(value: unknown): value is ProviderOptions { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/server/src/server/agent/provider-registry-wrap.test.ts b/packages/server/src/server/agent/provider-registry-wrap.test.ts index ae1254efa7..04d123ead1 100644 --- a/packages/server/src/server/agent/provider-registry-wrap.test.ts +++ b/packages/server/src/server/agent/provider-registry-wrap.test.ts @@ -6,6 +6,9 @@ import type { AgentSession, AgentStreamEvent, AgentRuntimeInfo, + SteerActiveTurnOptions, + SteerResult, + ImportedTimelineEntry, } from "./agent-sdk-types.js"; import { wrapSessionProvider } from "./provider-registry.js"; @@ -18,6 +21,7 @@ type OptionalAgentSessionMethodName = { }[keyof AgentSession]; const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -56,8 +60,12 @@ const RUNTIME_INFO: AgentRuntimeInfo = { class FakeSession implements AgentSession { readonly provider = "claude"; - readonly id = "session-1"; - readonly capabilities = CAPABILITIES; + id = "session-1"; + capabilities = CAPABILITIES; + initialTimeline: ImportedTimelineEntry[] = [ + { item: { type: "assistant_message", id: "initial", text: "Provider setup" } }, + ]; + readonly steers: Array<{ prompt: AgentPromptInput; options: SteerActiveTurnOptions }> = []; readonly features = []; readonly recordedCalls: string[] = []; @@ -71,6 +79,15 @@ class FakeSession implements AgentSession { return { turnId: "turn-1" }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + this.recordedCalls.push("steerActiveTurn"); + this.steers.push({ prompt, options }); + return { status: "accepted" }; + } + subscribe(_callback: (event: AgentStreamEvent) => void) { this.recordedCalls.push("subscribe"); return () => {}; @@ -172,6 +189,7 @@ describe("wrapSessionProvider", () => { const session = new FakeSession(); const wrapped = wrapSessionProvider("custom-claude", session); + await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" }); await wrapped.listCommands?.(); await wrapped.setModel?.("sonnet"); await wrapped.setThinkingOption?.("high"); @@ -182,7 +200,11 @@ describe("wrapSessionProvider", () => { const handler = wrapped.tryHandleOutOfBand?.("/compact"); await handler?.run({ emit: () => {} }); + expect(session.steers).toEqual([ + { prompt: "follow-up", options: { expectedTurnId: "turn-1" } }, + ]); expect(session.recordedCalls).toEqual([ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -194,4 +216,34 @@ describe("wrapSessionProvider", () => { "tryHandleOutOfBand.run", ]); }); + test("keeps provider-owned session values live", () => { + const session = new FakeSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + session.id = "session-2"; + session.capabilities = { ...CAPABILITIES, supportsMcpServers: false }; + expect(wrapped.id).toBe("session-2"); + expect(wrapped.capabilities).toEqual(session.capabilities); + expect(wrapped.initialTimeline).toEqual(session.initialTimeline); + }); + + test("propagates steering failure without interrupting or replacing the turn", async () => { + const error = new Error("Provider steer transport failed"); + class RejectingSession extends FakeSession { + override async steerActiveTurn(): Promise { + throw error; + } + } + const session = new RejectingSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + await expect(wrapped.steerActiveTurn!("follow-up", { expectedTurnId: "turn-1" })).rejects.toBe( + error, + ); + expect(session.recordedCalls).toEqual([]); + }); + + test("leaves steering unavailable when the provider has no implementation", () => { + const session: AgentSession = new FakeSession(); + session.steerActiveTurn = undefined; + expect(wrapSessionProvider("custom-claude", session).steerActiveTurn).toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9c4a22a4f8..9a2cb768f0 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -24,7 +24,7 @@ const CLAUDE_CUSTOM_THINKING_FIELDS = { const mockState = vi.hoisted(() => { interface ConstructorEntry { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; } @@ -36,17 +36,14 @@ const mockState = vi.hoisted(() => { cursor: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, trae: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, kimi: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, pi: [] as ConstructorEntry[], genericAcp: [] as Array<{ @@ -54,7 +51,6 @@ const mockState = vi.hoisted(() => { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }>, }, isCommandAvailable: vi.fn(async (_command: string) => false), @@ -261,13 +257,12 @@ vi.mock("./providers/pi/agent.js", () => ({ constructor(options: { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; }) { this.runtimeSettings = options.runtimeSettings; const entry: ConstructorEntry = { runtimeSettings: options.runtimeSettings, - providerParams: options.providerParams, }; if (options.commandsRpcType !== undefined) { entry.commandsRpcType = options.commandsRpcType; @@ -314,21 +309,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }) { - const providerParams = - options.providerParams && - typeof options.providerParams === "object" && - !Array.isArray(options.providerParams) - ? (options.providerParams as Record) - : {}; - this.capabilities = { - ...this.capabilities, - supportsMcpServers: - typeof providerParams.supportsMcpServers === "boolean" - ? providerParams.supportsMcpServers - : this.capabilities.supportsMcpServers, - }; this.runtimeSettings = { command: { mode: "replace", @@ -341,7 +322,6 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, }); } @@ -379,11 +359,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -394,7 +370,6 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ mockState.constructorArgs.cursor.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -445,11 +420,7 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -460,7 +431,6 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ mockState.constructorArgs.trae.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -498,11 +468,7 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -513,7 +479,6 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ mockState.constructorArgs.kimi.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -728,7 +693,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, { command: ["my-agent", "--acp"], @@ -737,7 +701,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, ]); }); @@ -812,43 +775,20 @@ test("ordinary custom ACP providers remain fail-closed for exact MCP grants", () ).toThrow(/cannot preapprove exact MCP tools for unattended execution/u); }); -test("ACP provider params can disable MCP support", () => { +test("ACP provider configuration retains opaque options", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { "no-mcp-acp": { extends: "acp", label: "No MCP ACP", command: ["no-mcp-acp", "serve"], - params: { - supportsMcpServers: false, - }, + options: { supportsMcpServers: false }, }, }, }); - - const client = registry["no-mcp-acp"].createClient(logger); - - expect(client.capabilities.supportsMcpServers).toBe(false); - expect(mockState.constructorArgs.genericAcp).toEqual([ - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - ]); + expect(registry["no-mcp-acp"].configuration?.providerOptions).toEqual({ + supportsMcpServers: false, + }); }); test("cursor provider extending acp uses CursorACPAgentClient", () => { @@ -872,14 +812,12 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => { env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, { command: ["cursor-agent", "acp"], env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -936,12 +874,10 @@ test("traecli provider extending acp uses TraeACPAgentClient", () => { { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -963,12 +899,10 @@ test("kimi provider extending acp uses KimiACPAgentClient", () => { { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 50ad1ae94d..bd22d37df3 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,6 +1,5 @@ import type { Logger } from "pino"; import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; -import { z } from "zod"; import type { AgentClient, @@ -49,10 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; -import { ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; -import { CodexProviderOptionsSchema } from "./providers/codex/options.js"; -import { OpenCodeProviderOptionsSchema } from "./providers/opencode/options.js"; -import { ToolPolicyUnsupportedError, validateProviderOptions } from "./provider-options.js"; +import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js"; import { AGENT_PROVIDER_DEFINITIONS, BUILTIN_PROVIDER_IDS, @@ -80,13 +76,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { * generic ACP providers (which only extend the literal "acp" sentinel). */ derivedFromProviderId: string | null; - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; - validateOptions: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -105,7 +95,15 @@ export interface ProviderDefinition extends AgentProviderDefinition { ) => Promise; } +/** Provider metadata and an unconfigured factory; the registry owns override application. */ +export interface RegisteredProviderDefinition extends AgentProviderDefinition { + iconSvg?: string; + supportsExactMcpPreapproval: boolean; + createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; +} + export interface BuildProviderRegistryOptions { + pluginProviders?: Record; runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; workspaceGitService?: Pick; @@ -120,7 +118,6 @@ interface ProviderClientFactoryOptions extends Pick< "workspaceGitService" | "managedProcesses" | "ompRuntime" > { openCodeBridge?: OpenCodeBridge; - providerParams?: unknown; customProvider?: { id: string; label: string; @@ -142,27 +139,23 @@ interface ResolvedProvider { profileModelsAreAdditive: boolean; enabled: boolean; derivedFromProviderId: string | null; - providerParams?: unknown; + providerOptions?: ProviderOptions; createBaseClient: (logger: Logger) => AgentClient; contract: ProviderContract; } interface ProviderContract { - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; applyToolPolicy?: (provider: string, toolPolicy: ToolPolicy) => ToolPolicy; } -const EmptyProviderOptionsSchema: z.ZodType = z.object({}).strict(); - const PROVIDER_CONTRACTS: Record = { - claude: { optionsSchema: ClaudeProviderOptionsSchema, supportsExactMcpPreapproval: true }, - codex: { optionsSchema: CodexProviderOptionsSchema, supportsExactMcpPreapproval: true }, - opencode: { optionsSchema: OpenCodeProviderOptionsSchema, supportsExactMcpPreapproval: true }, + claude: { supportsExactMcpPreapproval: true }, + codex: { supportsExactMcpPreapproval: true }, + opencode: { supportsExactMcpPreapproval: true }, }; const UNSUPPORTED_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: false, }; @@ -172,7 +165,6 @@ const HUB_E2E_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u; // The cross-repository Hub harness owns this synthetic provider ID. It exercises the production // registry path without extending exact-preapproval support to user-defined ACP providers. const HUB_E2E_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: true, applyToolPolicy: (provider, toolPolicy) => { for (const grant of toolPolicy.preapproved) { @@ -220,17 +212,15 @@ const PROVIDER_CLIENT_FACTORIES: Record = { managedProcesses: options?.managedProcesses, bridge: options?.openCodeBridge, }), - pi: (logger, runtimeSettings, options) => + pi: (logger, runtimeSettings) => new PiRpcAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, }), omp: (logger, runtimeSettings, options) => new OmpAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, runtime: options?.ompRuntime, }), mock: (logger) => new MockLoadTestAgentClient(logger), @@ -441,16 +431,28 @@ function mergeModelAdditions( ); } +// Every session member must cross this boundary, including optional capabilities. +type ForwardedAgentSession = { [K in keyof Required]: AgentSession[K] }; + export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { return { provider, - id: inner.id, - capabilities: inner.capabilities, + get id() { + return inner.id; + }, + get capabilities() { + return inner.capabilities; + }, + get initialTimeline() { + return inner.initialTimeline; + }, 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), subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))), async *streamHistory() { for await (const event of inner.streamHistory()) { @@ -458,7 +460,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession } }, getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), - getUsageReference: inner.getUsageReference?.bind(inner), getAvailableModes: () => inner.getAvailableModes(), getCurrentMode: () => inner.getCurrentMode(), setMode: (modeId) => inner.setMode(modeId), @@ -475,7 +476,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession revertFiles: inner.revertFiles?.bind(inner), revertBoth: inner.revertBoth?.bind(inner), tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner), - }; + } satisfies ForwardedAgentSession; } function wrapClientProvider( @@ -484,6 +485,7 @@ function wrapClientProvider( profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], profileModelsAreAdditive: boolean, + providerOptions: ProviderOptions | undefined, ): AgentClient { const listImportableSessions = inner.listImportableSessions?.bind(inner); const importSession = inner.importSession?.bind(inner); @@ -493,16 +495,21 @@ function wrapClientProvider( return { provider, - capabilities: inner.capabilities, - createSession: async (config, launchContext) => + get capabilities() { + return inner.capabilities; + }, + shutdown: inner.shutdown?.bind(inner), + createSession: async (config, launchContext, options) => wrapSessionProvider( provider, await inner.createSession( { ...config, provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, + options, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -513,18 +520,17 @@ function wrapClientProvider( ...handle, provider: inner.provider, }, - overrides - ? { - ...overrides, - provider: inner.provider, - } - : undefined, + { + ...overrides, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), + }, launchContext, options, ), ), fetchCatalog: async (options, context) => { - const catalog = await inner.fetchCatalog(options, context); + const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { @@ -536,7 +542,11 @@ function wrapClientProvider( resolveDefaultModeId: inner.resolveDefaultModeId ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) => await inner.resolveDefaultModeId?.({ - config: { ...config, provider: inner.provider }, + config: { + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }, env, signal, }) @@ -545,10 +555,15 @@ function wrapClientProvider( resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listFeatures: listFeatures - ? async (config) => await listFeatures({ ...config, provider: inner.provider }) + ? async (config) => + await listFeatures({ + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }) : undefined, listImportableSessions: listImportableSessions - ? async (options) => await listImportableSessions(options) + ? async (options) => await listImportableSessions({ ...options, providerOptions }) : undefined, importSession: importSession ? async (input, context) => { @@ -557,6 +572,10 @@ function wrapClientProvider( config: { ...context.config, provider: inner.provider, + providerOptions: mergeProviderOptions( + providerOptions, + context.config.providerOptions, + ), }, storedConfig: { ...context.storedConfig, @@ -573,6 +592,7 @@ function wrapClientProvider( config: { ...imported.config, provider, + providerOptions: context.storedConfig.providerOptions, }, persistence, }; @@ -595,7 +615,7 @@ function createRegistryEntry( provider: AgentProvider, resolved: ResolvedProvider, ): ProviderDefinition { - const modelClient = resolved.createBaseClient(logger); + const modelClient = createResolvedProviderClient(logger, provider, resolved); const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels); const additionalModels = resolveConfiguredModels( provider, @@ -626,11 +646,7 @@ function createRegistryEntry( configuration, enabled: resolved.enabled, derivedFromProviderId: resolved.derivedFromProviderId, - optionsSchema: resolved.contract.optionsSchema, supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval, - validateOptions: (options) => - validateProviderOptions(provider, resolved.contract.optionsSchema, options), - applyOptions: (config, options) => ({ ...config, providerOptions: options }), applyToolPolicy: (config, toolPolicy) => { if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) { throw new ToolPolicyUnsupportedError(provider); @@ -701,19 +717,41 @@ function createResolvedProviderClient( const inner = resolved.createBaseClient(logger); const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels); const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels); - const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0; - if (inner.provider === provider && !hasModelOverrides) { - return inner; - } return wrapClientProvider( provider, inner, profileModels, additionalModels, resolved.profileModelsAreAdditive, + resolved.providerOptions, ); } +interface RegisteredProvider { + definition: AgentProviderDefinition; + createClient: ProviderClientFactory; + contract: ProviderContract; + runtimeSettings?: ProviderRuntimeSettings; + override?: ProviderOverride; +} + +function resolveRegisteredProvider(input: RegisteredProvider): ResolvedProvider { + const { definition, override, contract } = input; + const runtimeSettings = mergeRuntimeSettings(input.runtimeSettings, toRuntimeSettings(override)); + return { + definition: applyOverrideToDefinition(definition, override), + runtimeSettings, + profileModels: override?.models ?? [], + additionalModels: override?.additionalModels ?? [], + profileModelsAreAdditive: false, + enabled: override?.enabled ?? definition.enabledByDefault ?? true, + derivedFromProviderId: null, + providerOptions: configuredProviderOptions(override), + createBaseClient: (logger) => input.createClient(logger, runtimeSettings), + contract, + }; +} + function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, @@ -732,30 +770,22 @@ function buildResolvedBuiltinProviders( for (const definition of definitions) { const override = providerOverrides[definition.id]; const factory = getProviderClientFactory(definition.id); - const mergedRuntimeSettings = mergeRuntimeSettings( - runtimeSettings?.[definition.id], - toRuntimeSettings(override), + resolvedProviders.set( + definition.id, + resolveRegisteredProvider({ + definition, + override, + runtimeSettings: runtimeSettings?.[definition.id], + createClient: (logger, settings) => + factory(logger, settings, { + workspaceGitService: options.workspaceGitService, + managedProcesses: options.managedProcesses, + ompRuntime: options.ompRuntime, + openCodeBridge: options.openCodeBridge, + }), + contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, + }), ); - - resolvedProviders.set(definition.id, { - definition: applyOverrideToDefinition(definition, override), - runtimeSettings: mergedRuntimeSettings, - profileModels: override?.models ?? [], - additionalModels: override?.additionalModels ?? [], - profileModelsAreAdditive: false, - enabled: override?.enabled ?? definition.enabledByDefault ?? true, - derivedFromProviderId: null, - providerParams: override?.params, - createBaseClient: (logger) => - factory(logger, mergedRuntimeSettings, { - workspaceGitService: options.workspaceGitService, - managedProcesses: options.managedProcesses, - ompRuntime: options.ompRuntime, - openCodeBridge: options.openCodeBridge, - providerParams: override?.params, - }), - contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, - }); } return resolvedProviders; @@ -772,7 +802,8 @@ function addDerivedProviders( } if (!override.extends) { - throw new Error(`Custom provider '${providerId}' requires an extends value`); + // Plugin overrides can precede registration (including disabled or removed plugins). + continue; } if (override.extends === "acp") { @@ -800,7 +831,7 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: null, - providerParams: override.params, + providerOptions: configuredProviderOptions(override), createBaseClient: (logger) => { const acpOptions = { logger, @@ -808,7 +839,6 @@ function addDerivedProviders( env: override.env, providerId, label: override.label ?? providerId, - providerParams: override.params, }; if (providerId === "cursor") { return new CursorACPAgentClient(acpOptions); @@ -846,7 +876,7 @@ function addDerivedProviders( ); const baseDefinition = baseProvider.definition; const baseFactory = getProviderClientFactory(baseProviderId); - const providerParams = override.params ?? baseProvider.providerParams; + const providerOptions = configuredProviderOptions(override) ?? baseProvider.providerOptions; resolvedProviders.set(providerId, { definition: createDerivedDefinition(providerId, baseDefinition, override), @@ -856,12 +886,11 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: baseProviderId, - providerParams, + providerOptions, createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings, { managedProcesses: options.managedProcesses, openCodeBridge: options.openCodeBridge, - providerParams, customProvider: { id: providerId, label: override.label ?? providerId, @@ -890,6 +919,28 @@ export function buildProviderRegistry( }, options?.isDev === true, ); + for (const [provider, definition] of Object.entries(options?.pluginProviders ?? {})) { + if (resolvedProviders.has(provider)) { + throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`); + } + const override = providerOverrides[provider]; + if (override?.extends) { + logger.warn({ provider }, "Plugin provider shadowed by configured provider"); + continue; + } + resolvedProviders.set( + provider, + resolveRegisteredProvider({ + definition, + override, + runtimeSettings: runtimeSettings?.[provider], + createClient: definition.createClient, + contract: { + supportsExactMcpPreapproval: definition.supportsExactMcpPreapproval, + }, + }), + ); + } addDerivedProviders(resolvedProviders, providerOverrides, { managedProcesses: options?.managedProcesses, openCodeBridge: options?.openCodeBridge, @@ -955,3 +1006,11 @@ export async function shutdownAgentClients( }), ); } + +function configuredProviderOptions( + override: ProviderOverride | undefined, +): ProviderOptions | undefined { + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. + // options wins as a whole record when both spellings are supplied. + return override?.options ?? override?.params; +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 3f81e5e9c7..66d86f8931 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -2,7 +2,11 @@ import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import type { + ProviderEvent, + ProviderRegistration, + ProviderLaunch, +} from "@getpaseo/plugin/server/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { @@ -234,7 +238,7 @@ describe("ProviderSnapshotManager public surface", () => { } }); - test("validates complete Hub agent configurations through the current provider contract", async () => { + test("validates catalog choices while leaving provider options opaque", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { @@ -289,11 +293,19 @@ describe("ProviderSnapshotManager public surface", () => { path: ["thinkingOptionId"], message: "Thinking option 'missing' is not available for provider 'codex'", }, - { - path: ["providerOptions", "sandbox_workspace_write", "network_access"], - message: "Invalid input: expected boolean, received string", - }, ]); + await expect( + manager.validateAgentConfiguration({ + provider: "codex", + model: "gpt-latest", + modeId: "auto-review", + thinkingOptionId: "xhigh", + providerOptions: { + sandbox_workspace_write: { network_access: "sometimes" }, + arbitrary: true, + }, + }), + ).resolves.toEqual([]); } finally { manager.destroy(); } @@ -3448,3 +3460,316 @@ test("binding a settled catalogue publishes once and rebinding an equal settled manager.destroy(); } }); + +describe("plugin provider overrides and launch", () => { + function provider() { + const launches: Array = []; + const registration: ProviderRegistration = { + id: "launch-plugin", + label: "Launch plugin", + command: [process.execPath, "default.js"], + async status({ launch }) { + if (launch?.env.PLUGIN_LOGIN !== "yes") { + return { available: false, diagnostic: "Sign in to the provider" }; + } + return { available: true }; + }, + async connect(request) { + launches.push(request.launch); + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: ["session.persistence"], + async send(input) { + if (input.type !== "catalog") return; + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: "runtime", label: "Runtime" }], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }; + return { registration, launches }; + } + + test("reports plugin status diagnostics without connecting", async () => { + const { registration, launches } = provider(); + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + manager.replacePluginProviders([registration]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "unavailable", + }); + expect((await manager.getProviderDiagnostic(registration.id)).diagnostic).toContain( + "Sign in to the provider", + ); + expect(launches).toEqual([]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("applies plugin overrides and refreshes the resolved launch on config edits", async () => { + const { registration, launches } = provider(); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + [registration.id]: { + enabled: false, + label: "Configured plugin", + models: [{ id: "configured", label: "Configured" }], + additionalModels: [{ id: "extra", label: "Extra" }], + command: [process.execPath, "override.js"], + env: { PLUGIN_LOGIN: "yes", CLAUDECODE: "parent" }, + }, + }, + }); + try { + manager.replacePluginProviders([registration]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + enabled: false, + status: "unavailable", + label: "Configured plugin", + }); + expect(launches).toEqual([]); + manager.applyMutableProviderConfig({ [registration.id]: { enabled: true } }); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + expect( + (await manager.getProvider({ provider: registration.id })).models?.map((model) => model.id), + ).toEqual(["configured", "extra"]); + expect(launches[0]).toMatchObject({ + command: process.execPath, + args: ["override.js"], + env: { PLUGIN_LOGIN: "yes" }, + }); + expect(launches[0]!.env).not.toHaveProperty("CLAUDECODE"); + const previous = manager.getAgentManagerProviderState().clients[registration.id]; + manager.applyMutableProviderConfig({ + [registration.id]: { + enabled: true, + command: [process.execPath, "edited.js"], + env: { PLUGIN_LOGIN: "no" }, + }, + }); + expect(manager.getAgentManagerProviderState().clients[registration.id]).not.toBe(previous); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "unavailable", + }); + manager.applyMutableProviderConfig({ + [registration.id]: { + enabled: true, + command: [process.execPath, "edited.js"], + env: { PLUGIN_LOGIN: "yes" }, + }, + }); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + expect(launches.at(-1)).toMatchObject({ args: ["edited.js"] }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("does not warn for a plugin override during startup before provider registration settles", async () => { + const { registration } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + [registration.id]: { command: [process.execPath], env: { PLUGIN_LOGIN: "yes" } }, + }, + }); + try { + expect(warnings).toEqual([]); + manager.replacePluginProviders([]); + expect(warnings).toEqual([]); + manager.replacePluginProviders([registration]); + manager.settlePluginProviders(); + expect(warnings).toEqual([]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("warns for unknown overrides when plugin startup settles without registered plugins", async () => { + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { "typo-provider": { enabled: false } }, + }); + try { + expect(warnings).toEqual([]); + manager.settlePluginProviders(); + manager.settlePluginProviders(); + expect(warnings).toHaveLength(1); + expect(JSON.parse(warnings[0]!)).toMatchObject({ + provider: "typo-provider", + msg: "Provider override matches no registered provider", + }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("warns once for unknown overrides after plugin startup settles without warning for known providers", async () => { + const { registration } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + "typo-provider": { enabled: false }, + [registration.id]: { enabled: false }, + codex: { enabled: false }, + }, + }); + try { + expect(warnings).toEqual([]); + manager.replacePluginProviders([registration]); + expect(warnings).toEqual([]); + manager.settlePluginProviders(); + manager.settlePluginProviders(); + const unknown = warnings.map((line) => JSON.parse(line) as { provider: string; msg: string }); + expect(unknown).toEqual([ + expect.objectContaining({ + provider: "typo-provider", + msg: "Provider override matches no registered provider", + }), + ]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("a same-ID extends entry shadows the plugin without throwing", async () => { + const { registration, launches } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + [registration.id]: { + extends: "acp", + label: "Own provider", + command: [process.execPath], + enabled: true, + }, + }, + }); + try { + const previous = manager.getAgentManagerProviderState().clients[registration.id]; + manager.replacePluginProviders([registration]); + expect(manager.getAgentManagerProviderState().clients[registration.id]).toBe(previous); + expect(manager.getProviderLabel(registration.id)).toBe("Own provider"); + expect( + manager.getAgentManagerProviderState().providerDefinitions[registration.id], + ).toMatchObject({ enabled: true }); + expect(warnings.join("\n")).toContain("shadowed"); + expect(launches).toEqual([]); + manager.replacePluginProviders([]); + expect(manager.hasProvider(registration.id)).toBe(true); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); +}); + +test("model overrides preserve negotiated plugin capabilities and connection shutdown", async () => { + let closed = 0; + const registration: ProviderRegistration = { + id: "model-plugin", + label: "Model plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: ["session.persistence"], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() { + closed += 1; + }, + }; + }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + [registration.id]: { models: [{ id: "configured", label: "Configured" }] }, + }, + }); + try { + manager.replacePluginProviders([registration]); + await manager.getProvider({ provider: registration.id, wait: true }); + const client = manager.getAgentManagerProviderState().clients[registration.id]!; + expect(client.capabilities.supportsSessionPersistence).toBe(true); + await manager.shutdown(); + expect(closed).toBe(1); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 0307dc14be..82acd13e1f 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -185,10 +185,7 @@ export interface AgentManagerProviderState { providerDefinitions: Partial< Record< AgentProvider, - Pick< - ProviderDefinition, - "enabled" | "derivedFromProviderId" | "validateOptions" | "applyOptions" | "applyToolPolicy" - > + Pick > >; clients: Partial>; @@ -256,6 +253,7 @@ export class ProviderSnapshotManager { private providerClients: Record; private readonly ownedClients = new Set(); private readonly pluginProviders: PluginAgentClientRegistry; + private pluginProvidersSettled = false; constructor(options: ProviderSnapshotManagerOptions) { this.logger = options.logger; @@ -281,7 +279,6 @@ export class ProviderSnapshotManager { ); this.providerClients = { ...this.extraClients, - ...this.pluginProviders.clients(), } as Record; for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } @@ -369,8 +366,6 @@ export class ProviderSnapshotManager { providerDefinitions[provider] = { enabled: definition.enabled, derivedFromProviderId: definition.derivedFromProviderId, - validateOptions: definition.validateOptions, - applyOptions: definition.applyOptions, applyToolPolicy: definition.applyToolPolicy, }; if (definition.enabled) { @@ -385,17 +380,28 @@ export class ProviderSnapshotManager { return { providerDefinitions, clients }; } + /** Called after built-in and configured plugin startup has completed, including disabled plugins. */ + settlePluginProviders(): void { + if (this.pluginProvidersSettled) return; + this.pluginProvidersSettled = true; + this.warnUnknownProviderOverrides(); + } + + private warnUnknownProviderOverrides(): void { + if (!this.pluginProvidersSettled) return; + for (const [provider, override] of Object.entries(this.providerOverrides ?? {})) { + if (!override.extends && !this.generation.definitions[provider]) { + this.logger.warn({ provider }, "Provider override matches no registered provider"); + } + } + } + replacePluginProviders( registrations: readonly ProviderRegistration[], ): AgentManagerProviderState { for (const registration of registrations) { - if ( - (this.generation.definitions[registration.id] || this.extraClients[registration.id]) && - !this.pluginProviders.has(registration.id) - ) { - throw new Error( - `Plugin provider '${registration.id}' conflicts with a configured provider`, - ); + if (BUILTIN_PROVIDER_IDS.includes(registration.id) || this.extraClients[registration.id]) { + throw new Error(`Plugin provider '${registration.id}' conflicts with a built-in provider`); } } const previousPlugins = this.pluginProviders.definitions(); @@ -405,17 +411,30 @@ export class ProviderSnapshotManager { this.pluginProviders.replace(registrations); const plugins = this.pluginProviders.definitions(); const retiredProviders = Object.keys(previousPlugins).filter( - (provider) => previousPlugins[provider] !== plugins[provider], + (provider) => + previousPlugins[provider] !== plugins[provider] && + !this.providerOverrides?.[provider]?.extends, ); - const definitions = { ...this.generation.definitions }; + const definitions = this.buildRegistry(this.runtimeSettings, this.providerOverrides); const changed = new Set(); - for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) { - if (previousPlugins[provider] !== plugins[provider]) changed.add(provider); - delete definitions[provider]; - delete clients[provider]; + for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) { + const before = this.generation.definitions[provider]; + const after = definitions[provider]; + const registrationChanged = + previousPlugins[provider] !== plugins[provider] && + !this.providerOverrides?.[provider]?.extends; + if ( + registrationChanged || + !before || + !after || + !isDeepStrictEqual(before.configuration, after.configuration) + ) { + changed.add(provider); + delete clients[provider]; + } else { + definitions[provider] = before; + } } - Object.assign(definitions, plugins); - Object.assign(clients, this.pluginProviders.clients()); for (const client of Object.values(clients)) this.ownedClients.add(client); const generation = this.createGeneration(definitions, this.providerOverrides); const state = this.createAgentManagerState(definitions, clients); @@ -492,11 +511,9 @@ export class ProviderSnapshotManager { ]; } - const definition = this.requireProvider(input.provider); return validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions: definition.validateOptions, }); } @@ -607,7 +624,7 @@ export class ProviderSnapshotManager { definitions[provider] = before; } } - Object.assign(clients, this.extraClients, this.pluginProviders.clients()); + Object.assign(clients, this.extraClients); const generation = this.createGeneration(definitions, providerOverrides); const agentManagerState = this.createAgentManagerState(definitions, clients); return { @@ -631,6 +648,7 @@ export class ProviderSnapshotManager { } this.generation = generation; this.providerClients = clients; + this.warnUnknownProviderOverrides(); for (const [key, catalogs] of this.catalogs) { for (const provider of changed) catalogs.delete(provider); if (catalogs.size === 0) this.catalogs.delete(key); @@ -689,19 +707,13 @@ export class ProviderSnapshotManager { const registry = buildProviderRegistry(this.logger, { runtimeSettings, providerOverrides, + pluginProviders: this.pluginProviders.definitions(), workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, openCodeBridge: this.openCodeBridge, isDev: this.isDev, }); - for (const [provider, definition] of Object.entries(this.pluginProviders.definitions())) { - if (registry[provider]) { - throw new Error(`Plugin provider '${provider}' conflicts with a configured provider`); - } - registry[provider] = definition; - } - for (const [provider, client] of Object.entries(this.extraClients) as Array< [AgentProvider, AgentClient] >) { 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 0394900d3b..f847745d69 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -222,13 +222,6 @@ function createSessionWithConfig( ); } -test("ACP usage reference uses the provider ID", async () => { - for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) { - const session = createSessionWithConfig({ provider }); - expect(await session.getUsageReference()).toEqual({ source: provider, input: {} }); - } -}); - function createKiroSession( options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {}, logger: ReturnType = createTestLogger(), @@ -832,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 019725ce3f..de8055c787 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1,3 +1,4 @@ +import { ACPProviderOptionsSchema } from "./acp-options.js"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; @@ -200,15 +201,22 @@ export function summarizeACPRequestError(error: unknown): { return { message: String(error) }; } +// Keeps the JSON-RPC code so callers can still tell invalid params from other failures. +class ACPRequestError extends Error { + readonly code: number; + + constructor(error: ACPError) { + super(summarizeACPRequestError(error).message, { cause: error }); + this.name = "ACPRequestError"; + this.code = error.code; + } +} + function toACPRequestError(error: unknown): Error { if (!isACPError(error)) { return error instanceof Error ? error : new Error(String(error)); } - - const summary = summarizeACPRequestError(error); - const next = new Error(summary.message); - next.name = "ACPRequestError"; - return next; + return new ACPRequestError(error); } function resolveTerminalCommand( @@ -954,6 +962,7 @@ export class ACPAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { this.assertProvider(config); + const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); const session = new ACPAgentSession( { ...config, provider: this.provider }, { @@ -966,14 +975,18 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1006,6 +1019,7 @@ export class ACPAgentClient implements AgentClient { provider: this.provider, cwd, }; + const providerOptions = ACPProviderOptionsSchema.parse(mergedConfig.providerOptions ?? {}); const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, @@ -1016,14 +1030,18 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, handle, agentId: launchContext?.agentId, launchEnv: launchContext?.env, @@ -1075,6 +1093,7 @@ export class ACPAgentClient implements AgentClient { raceProviderRefreshAbort( context?.signal, this.spawnProcess(PROBE_ENV, { + providerOptions: options.providerOptions, onSpawned: (spawned) => { probe = spawned; if (context?.signal.aborted) void closeProbe().catch(() => undefined); @@ -1320,6 +1339,7 @@ export class ACPAgentClient implements AgentClient { launchEnv?: Record, options?: { initializeTimeoutMs?: number; + providerOptions?: Record; onSpawned?: (probe: UninitializedACPProcess) => void; client?: ACPClient; }, @@ -1332,7 +1352,11 @@ export class ACPAgentClient implements AgentClient { }; options?.onSpawned?.(probe); try { - const initialize = await this.initializeTransport(transport, options?.initializeTimeoutMs); + const initialize = await this.initializeTransport( + transport, + options?.initializeTimeoutMs, + options?.providerOptions, + ); const initializedProbe: SpawnedACPProcess = { ...probe, initialize, @@ -1391,7 +1415,9 @@ export class ACPAgentClient implements AgentClient { protected async initializeTransport( transport: ACPProcessTransport, initializeTimeoutMs?: number, + providerOptions?: Record, ): Promise { + const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {}); let timeout: ReturnType | null = null; const initializeTimeoutPromise = initializeTimeoutMs ? new Promise((_, reject) => { @@ -1408,7 +1434,7 @@ export class ACPAgentClient implements AgentClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: buildACPClientCapabilities( this.clientCapabilityMeta, - this.clientCapabilities, + configuredOptions.clientCapabilities ?? this.clientCapabilities, ), clientInfo: { name: "Paseo", version: "dev" }, }), @@ -1633,10 +1659,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { readonly provider: string; readonly capabilities: AgentCapabilityFlags; - async getUsageReference() { - return { source: this.provider, input: {} }; - } - private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly defaultCommand: [string, ...string[]]; @@ -2025,8 +2047,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { } const context = this.createProviderModeWriterContext(modeId, selection); - const providerResult = this.providerModeWriter - ? await this.providerModeWriter(context) + const providerModeWriter = this.providerModeWriter; + const providerResult = providerModeWriter + ? await this.runACPRequest(() => providerModeWriter(context)) : { handled: false }; if (providerResult.handled) { this.currentMode = providerResult.currentModeId ?? modeId; @@ -2071,15 +2094,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - if (this.beforeModeWriter) { - const beforeResult = await this.beforeModeWriter(context); + const beforeModeWriter = this.beforeModeWriter; + if (beforeModeWriter) { + const beforeResult = await this.runACPRequest(() => beforeModeWriter(context)); if (beforeResult?.configOptions) { this.configOptions = this.transformConfigOptions(beforeResult.configOptions); } } if (selection.hasAvailableModes) { - await this.connection.setSessionMode({ sessionId: this.sessionId, modeId }); + const { connection, sessionId } = this; + await this.runACPRequest(() => connection.setSessionMode({ sessionId, modeId })); this.currentMode = modeId; this.pushEvent({ type: "mode_changed", @@ -2095,11 +2120,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error(`${this.provider} does not expose ACP mode switching`); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: modeOption.id, - value: modeId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: modeOption.id, + value: modeId, + }), + ); this.currentMode = this.applyConfigOptionResponse({ response, configId: modeOption.id, @@ -2210,11 +2238,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: modelOption.id, - value: modelId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: modelOption.id, + value: modelId, + }), + ); this.currentModel = this.applyConfigOptionResponse({ response, configId: modelOption.id, @@ -2238,8 +2269,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - if (this.thinkingOptionWriter) { - await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId); + const thinkingOptionWriter = this.thinkingOptionWriter; + if (thinkingOptionWriter) { + const { connection, sessionId } = this; + await this.runACPRequest(() => thinkingOptionWriter(connection, sessionId, thinkingOptionId)); this.thinkingOptionId = thinkingOptionId; this.pushEvent({ type: "thinking_option_changed", @@ -2256,11 +2289,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!option) { throw new Error(`${this.provider} does not expose ACP thought-level selection`); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: option.id, - value: thinkingOptionId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: option.id, + value: thinkingOptionId, + }), + ); this.thinkingOptionId = this.applyConfigOptionResponse({ response, configId: option.id, @@ -2306,11 +2342,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { ); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: option.id, - value: requestedValue, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: option.id, + value: requestedValue, + }), + ); const currentValue = this.applyConfigOptionResponse({ response, configId: option.id, @@ -2399,12 +2438,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.sessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; return { provider: this.provider, sessionId: this.sessionId, nativeHandle: this.sessionId, metadata: { - ...this.config, + ...persistedConfig, title: this.currentTitle, }, }; @@ -3138,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/acp-options.ts b/packages/server/src/server/agent/providers/acp-options.ts new file mode 100644 index 0000000000..4f75456e38 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-options.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ACPProviderOptionsSchema = z + .object({ + supportsMcpServers: z.boolean().optional(), + clientCapabilities: z + .object({ + fs: z + .object({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + }) + .optional(), + terminal: z.boolean().optional(), + }) + .optional(), + }) + .passthrough(); 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.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts new file mode 100644 index 0000000000..223e2001f5 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import pino from "pino"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +/** + * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. + * The daemon log at its default level has to say which, and where it looked. + */ + +const SESSION_ID = "history-load-session"; + +interface LogRecord { + level: number; + msg: string; + sessionId?: string; + historyPath?: string; + err?: { message?: string }; +} + +describe("ClaudeAgentSession persisted history load", () => { + let tempRoot: string; + let cwd: string; + let configDir: string; + let records: LogRecord[]; + + async function resume(): Promise { + const client = new ClaudeAgentClient({ + logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), + queryFactory: vi.fn(() => { + throw new Error("history load must not start a query"); + }), + resolveVersion: async () => "2.1.220", + }); + const session = await client.resumeSession( + { provider: "claude", sessionId: SESSION_ID }, + { cwd }, + ); + await session.close(); + } + + function transcriptPath(): string { + return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`); + } + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-load-")); + cwd = path.join(tempRoot, "repo"); + configDir = path.join(tempRoot, "claude-config"); + mkdirSync(cwd, { recursive: true }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + records = []; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("logs the resolved transcript path when the transcript does not exist", async () => { + await resume(); + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.info, + msg: "No Claude transcript to load history from", + sessionId: SESSION_ID, + historyPath: transcriptPath(), + }), + ); + }); + + test("logs a warning with the error when the transcript cannot be read", async () => { + // A directory at the transcript path exists but cannot be read as a file. + mkdirSync(transcriptPath(), { recursive: true }); + + await resume(); + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to load Claude history from transcript", + sessionId: SESSION_ID, + historyPath: transcriptPath(), + err: expect.objectContaining({ message: expect.stringContaining("EISDIR") }), + }), + ); + }); +}); 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 16aa193cce..d28ac910ac 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -25,7 +25,6 @@ import type { } from "../../agent-sdk-types.js"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; -import { buildProviderRegistry } from "../../provider-registry.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; @@ -530,56 +529,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("ClaudeAgentClient binary resolution", () => { const logger = createTestLogger(); - test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => { - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); - const session = await client.createSession( - { provider: "claude", cwd: process.cwd() }, - { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } }, - ); - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { configDir: "/accounts/second" }, - }); - await session.close(); - }); - - test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])( - "Claude usage reference is null with %s override", - async (name) => { - const client = new ClaudeAgentClient({ - logger, - resolveBinary: async () => "/test/claude/bin", - }); - const session = await client.createSession( - { provider: "claude", cwd: process.cwd() }, - { env: { [name]: "override" } }, - ); - expect(await session.getUsageReference?.()).toBeNull(); - await session.close(); - }, - ); - - test("Claude custom alias keeps its CLAUDE_CONFIG_DIR usage reference", async () => { - const registry = buildProviderRegistry(logger, { - providerOverrides: { - "work-claude": { - extends: "claude", - label: "Work Claude", - env: { CLAUDE_CONFIG_DIR: "/accounts/work" }, - }, - }, - }); - const session = await registry["work-claude"].createClient(logger).createSession({ - provider: "work-claude", - cwd: process.cwd(), - }); - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { configDir: "/accounts/work" }, - }); - await session.close(); - }); - test("resolves the installed Claude Code version", async () => { await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); }); @@ -1060,13 +1009,42 @@ describe("ClaudeAgentSession features", () => { expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ effort: "xhigh", - thinking: { type: "adaptive" }, + thinking: { type: "adaptive", display: "summarized" }, settings: { ultracode: true }, }); await session.close(); }); + test("disables Claude hooks for internal agents only", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const internalSession = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + internal: true, + }); + const userSession = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + await internalSession.startTurn("hello"); + await userSession.startTurn("hello"); + + expect(queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({ + disableAllHooks: true, + }); + expect(queryFactory.mock.calls[1]?.[0].options.settings).toBeUndefined(); + + await internalSession.close(); + await userSession.close(); + }); + test("turns Claude thinking off without retaining an effort level", async () => { const { queryFactory, launches } = createQueryMock(); const client = new ClaudeAgentClient({ @@ -1245,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 38476490b3..36f991d019 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,9 +1,9 @@ +import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import path from "node:path"; -import { homedir } from "node:os"; import { type AgentDefinition, type CanUseTool, @@ -1693,7 +1693,8 @@ export class ClaudeAgentClient implements AgentClient { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); } const model = config.model?.trim(); - const providerOptions = ClaudeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("claude", ClaudeProviderOptionsSchema, config.providerOptions) ?? {}; return { ...config, provider: "claude", @@ -2042,15 +2043,6 @@ class ClaudeAgentSession implements AgentSession { readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; - async getUsageReference() { - const env = this.buildSdkEnv(); - if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null; - return { - source: "claude", - input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") }, - }; - } - private readonly config: ClaudeAgentConfig; private readonly launchEnv?: Record; private readonly agentId?: string; @@ -2061,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. */ @@ -2136,6 +2130,7 @@ class ClaudeAgentSession implements AgentSession { this.agentId = options.agentId; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; + this.harnessEnvironment = this.buildSdkEnv(); this.persistSession = options.persistSession; this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; @@ -2430,7 +2425,7 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; - assertClaudeModeCanRun(normalized, this.buildSdkEnv()); + assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; const activeQuery = await this.ensureQuery(); await activeQuery.setPermissionMode(normalized); @@ -2669,11 +2664,12 @@ class ClaudeAgentSession implements AgentSession { if (!this.claudeSessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; this.persistence = { provider: "claude", sessionId: this.claudeSessionId, nativeHandle: this.claudeSessionId, - metadata: { ...this.config }, + metadata: { ...persistedConfig }, }; return this.persistence; } @@ -3248,10 +3244,18 @@ class ClaudeAgentSession implements AgentSession { return { thinking: { type: "disabled" }, effort: undefined, ultracode: false }; } if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) { - return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + ultracode: true, + }; } if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: thinkingOptionId, + ultracode: false, + }; } return { thinking: undefined, effort: undefined, ultracode: false }; } @@ -3262,7 +3266,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, @@ -3278,7 +3292,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(); @@ -3363,13 +3377,17 @@ class ClaudeAgentSession implements AgentSession { input: { ultracode: boolean }, ): Pick | Record { const fastMode = this.resolveFastModeSetting(); - if (fastMode === null && !input.ultracode) { + // Internal agents do daemon work such as naming a branch, so the user's and + // project's hooks must not run for them. + const disableAllHooks = this.config.internal === true; + if (fastMode === null && !input.ultracode && !disableAllHooks) { return {}; } return { settings: mergeClaudeSettings(providerOptions.settings, { ...(fastMode === null ? {} : { fastMode }), ...(input.ultracode ? { ultracode: true } : {}), + ...(disableAllHooks ? { disableAllHooks: true } : {}), }), }; } @@ -4884,10 +4902,15 @@ class ClaudeAgentSession implements AgentSession { } private loadPersistedHistory(sessionId: string): void { + let historyPath: string | null = null; try { this.taskState.reset(); - const historyPath = this.resolveHistoryPath(sessionId); + historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { + this.logger.info( + { sessionId, cwd: this.config.cwd, historyPath }, + "No Claude transcript to load history from", + ); return; } const content = fs.readFileSync(historyPath, "utf8"); @@ -4896,8 +4919,11 @@ class ClaudeAgentSession implements AgentSession { readClaudeSidechainHistory(historyPath), ); this.ingestPersistedHistory(content, replay); - } catch { - // ignore history load failures + } catch (error) { + this.logger.warn( + { err: error, sessionId, historyPath }, + "Failed to load Claude history from transcript", + ); } } @@ -5059,7 +5085,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.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 2e20f5f6f1..8f3f8fc76d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -19,6 +19,41 @@ interface CollaborationModeRecord { developer_instructions?: string | null; } +const TEST_SPEED_MODELS = [ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +].map((id) => ({ + id, + isDefault: id === "gpt-5.4", + defaultReasoningEffort: "medium", + serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }], +})); + +function speedFeature(value: string) { + return { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }; +} + const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [ { name: "Code", @@ -65,7 +100,7 @@ function createConfig(overrides: Partial = {}): AgentSession function createSessionHarness( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): { session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -73,6 +108,7 @@ function createSessionHarness( const config = createConfig(configOverrides); const appServer = createFakeCodexAppServer({ "collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }), + "model/list": () => ({ data: options.models ?? TEST_SPEED_MODELS }), }); const session = new CodexAppServerAgentSession( { ...config, provider: CODEX_PROVIDER }, @@ -85,7 +121,7 @@ function createSessionHarness( async function createConnectedSession( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): Promise<{ session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -97,8 +133,169 @@ async function createConnectedSession( } describe("Codex app-server provider features", () => { + test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => { + const { session, appServer } = await createConnectedSession( + { model: "gpt-6.1-sol" }, + { + models: [ + { + id: "gpt-6.1-sol", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Faster processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Lowest latency" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + type: "select", + value: "default", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "ultrafast", label: "Ultrafast" }, + ], + }), + ); + await session.setFeature?.("service_tier", "ultrafast"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "ultrafast", + }); + } finally { + await session.close(); + } + }); + + test("new models and service tiers work without a Paseo catalog update", async () => { + const { session, appServer } = await createConnectedSession( + { model: "future-codex-model" }, + { + models: [ + { + id: "future-codex-model", + serviceTiers: [ + { id: "future-tier", name: "Future speed", description: "A newly advertised tier" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "future-tier", label: "Future speed" }, + ], + }), + ); + await session.setFeature?.("service_tier", "future-tier"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model: "future-codex-model", + serviceTier: "future-tier", + }); + } finally { + await session.close(); + } + }); + + test.each(["priority", "ultrafast"])( + "restores %s and Normal explicitly overrides an existing thread tier", + async (tier) => { + const { session, appServer } = await createConnectedSession( + { model: "future-model", featureValues: { service_tier: tier } }, + { + models: [ + { + id: "future-model", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Fast processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: tier }), + ); + await session.getRuntimeInfo(); + await session.setFeature?.("service_tier", "default"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", + }); + } finally { + await session.close(); + } + }, + ); + + test("rejects unadvertised tiers and non-string values", async () => { + const { session } = await createConnectedSession(); + try { + for (const value of ["ultrafast", "bogus", true, null]) { + await expect(session.setFeature?.("service_tier", value)).rejects.toThrow( + "is not available", + ); + } + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + } finally { + await session.close(); + } + }); + + test("switching to a model without the selected tier resets speed", async () => { + const { session, appServer } = await createConnectedSession( + { model: "ultra-model", featureValues: { service_tier: "ultrafast" } }, + { + models: [ + { + id: "ultra-model", + serviceTiers: [{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }], + }, + ...TEST_SPEED_MODELS, + ], + }, + ); + try { + await session.setModel("gpt-6.1-sol"); + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); + } finally { + await session.close(); + } + }); + + test("maps legacy Fast commands to the catalog's request id", async () => { + const { session, appServer } = await createConnectedSession(); + try { + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "priority", + }); + } finally { + await session.close(); + } + }); + test.each([ "gpt-6-astra", + "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6", @@ -112,15 +309,15 @@ describe("Codex app-server provider features", () => { try { expect(session.features).toContainEqual( expect.objectContaining({ - id: "fast_mode", - value: false, + id: "service_tier", + value: "default", }), ); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ model, - serviceTier: "fast", + serviceTier: "priority", }); } finally { await session.close(); @@ -149,8 +346,8 @@ describe("Codex app-server provider features", () => { `Codex fast mode is not available for model '${model}'`, ); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); } finally { await session.close(); @@ -165,8 +362,8 @@ describe("Codex app-server provider features", () => { try { expect(session.features).toContainEqual( expect.objectContaining({ - id: "fast_mode", - value: true, + id: "service_tier", + value: "priority", }), ); await session.setModel("gpt-5.6-sol"); @@ -174,26 +371,18 @@ describe("Codex app-server provider features", () => { await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ model: "gpt-6-astra", - serviceTier: "fast", + serviceTier: "priority", }); } finally { await session.close(); } }); - test("features returns fast and plan toggles when supported", async () => { + test("features returns speed selector and plan toggle when supported", async () => { const { session } = await createConnectedSession(); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: false, - }, + speedFeature("default"), { type: "toggle", id: "plan_mode", @@ -205,19 +394,11 @@ describe("Codex app-server provider features", () => { }, ]); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setFeature?.("plan_mode", true); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -265,23 +446,23 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); - test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { + test("selecting Fast sends the catalog service tier", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); - test("setFeature('fast_mode', false) clears serviceTier to null", async () => { + test("legacy Fast off explicitly selects Normal", async () => { const { session, appServer } = await createConnectedSession({ featureValues: { fast_mode: true }, }); @@ -289,8 +470,8 @@ describe("Codex app-server provider features", () => { await session.setFeature?.("fast_mode", false); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); @@ -330,15 +511,7 @@ describe("Codex app-server provider features", () => { }); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -352,7 +525,7 @@ describe("Codex app-server provider features", () => { await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", collaborationMode: expect.objectContaining({ mode: "plan", }), @@ -362,11 +535,11 @@ describe("Codex app-server provider features", () => { test("startTurn includes serviceTier when fast mode is enabled", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); @@ -388,7 +561,7 @@ describe("Codex app-server provider features", () => { msg: "Starting Codex app-server turn", model: "gpt-5.4", modeId: "auto", - serviceTier: "fast", + serviceTier: "priority", cwd: "/tmp/codex-fast-mode-test", }); expect(JSON.stringify(entry)).not.toContain(prompt); @@ -397,7 +570,7 @@ describe("Codex app-server provider features", () => { test("setModel clears fast mode when switching to an unsupported model", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setModel("gpt-3.5-turbo"); expect(session.features).toEqual([ @@ -413,8 +586,8 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); 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 1d84dbca0c..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 @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import type { AgentLaunchContext, + AgentPermissionRequest, AgentSession, AgentSessionConfig, AgentSlashCommand, @@ -171,44 +172,6 @@ function createSession( return session; } -test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => { - const session = new CodexAppServerAgentSession( - createConfig(), - null, - createTestLogger(), - () => { - throw new Error("unused"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { CODEX_HOME: "/accounts/second" }, - ); - expect(await session.getUsageReference()).toEqual({ - source: "codex", - input: { codexHome: "/accounts/second" }, - }); - const custom = new CodexAppServerAgentSession( - createConfig(), - null, - createTestLogger(), - () => { - throw new Error("unused"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" }, - ); - expect(await custom.getUsageReference()).toBeNull(); -}); - function createProviderWithFakeAppServer( appServer: FakeCodexAppServer, options?: { @@ -735,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: () => @@ -745,6 +713,7 @@ process.stdin.on("data", (chunk) => { }); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); vi.unstubAllEnvs(); rmSync(tempDir, { recursive: true, force: true }); } @@ -804,6 +773,7 @@ let buffer = ""; function resultFor(method, params) { if (method === "initialize") return {}; if (method === "collaborationMode/list") return { data: [] }; + if (method === "model/list") return { data: [] }; if (method === "skills/list") { const cwds = params && params.cwds; const projectCwd = ${JSON.stringify(projectCwd)}; @@ -1170,6 +1140,53 @@ describe("Codex app-server provider", () => { } }); + test("keeps the default effort when a session without one is resumed after a restart", async () => { + const beforeRestart = createFakeCodexAppServer(); + const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession( + createConfig({ thinkingOptionId: undefined }), + ); + await firstSession.startTurn("first turn"); + await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + const handle = firstSession.describePersistence()!; + await firstSession.close(); + + const afterRestart = createFakeCodexAppServer(); + // The daemon resumes with the stored agent config, which never recorded an effort. + const resumed = await createProviderWithFakeAppServer(afterRestart).resumeSession(handle, { + model: "gpt-5.4", + thinkingOptionId: undefined, + }); + + try { + await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({ + thinkingOptionId: "medium", + }); + await resumed.startTurn("turn after restart"); + await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + beforeRestart.assertNoErrors(); + afterRestart.assertNoErrors(); + } finally { + await resumed.close(); + } + }); + + test("provider persistence leaves options owned by the stored agent config", async () => { + const appServer = createFakeCodexAppServer(); + const provider = createProviderWithFakeAppServer(appServer); + const session = await provider.createSession( + createConfig({ providerOptions: { sandbox_mode: "read-only", approval_policy: "never" } }), + ); + try { + await session.startTurn("persist the configured session"); + await appServer.waitForTurnStart(); + const handle = session.describePersistence()!; + expect(handle.metadata).not.toHaveProperty("providerOptions"); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -1353,6 +1370,75 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("answers each concurrent command approval callback that shares one item", async () => { + const appServer = createFakeCodexAppServer({ + initialize: () => ({}), + "collaborationMode/list": () => ({ data: [] }), + "skills/list": () => ({ data: [] }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const requested: AgentPermissionRequest[] = []; + session.subscribe((event) => { + if (event.type === "permission_requested") requested.push(event.request); + }); + + try { + await session.connect(); + + const firstPermission = waitForNextPermission(session); + appServer.requestCommandApproval({ + itemId: "shared-item", + approvalId: "callback-a", + threadId: "thread-1", + turnId: "turn-1", + command: "git add a.txt", + cwd: "/workspace/project", + reason: "subcommand a", + }); + await firstPermission; + const secondPermission = waitForNextPermission(session); + appServer.requestCommandApproval({ + itemId: "shared-item", + approvalId: "callback-b", + threadId: "thread-1", + turnId: "turn-1", + command: "git add b.txt", + cwd: "/workspace/project", + reason: "subcommand b", + }); + await secondPermission; + + expect(requested.map((request) => request.input?.command)).toEqual([ + "git add a.txt", + "git add b.txt", + ]); + expect(new Set(requested.map((request) => request.id)).size).toBe(2); + expect(session.getPendingPermissions()).toHaveLength(2); + + await session.respondToPermission(requested[0]!.id, { behavior: "allow" }); + await session.respondToPermission(requested[1]!.id, { + behavior: "deny", + message: "not b", + }); + + await expect(appServer.waitForCommandApprovalDecision("callback-a")).resolves.toEqual({ + decision: "accept", + }); + await expect(appServer.waitForCommandApprovalDecision("callback-b")).resolves.toEqual({ + decision: "decline", + }); + expect(session.getPendingPermissions()).toHaveLength(0); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("shows a successful shell command that produces no output", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( @@ -1515,6 +1601,51 @@ describe("Codex app-server provider", () => { } }); + test.each(["python3 -i", "/usr/bin/zsh -lc 'python3 -i'"])( + "labels a terminal write started as %s with the command that started the terminal", + async (command) => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + await session.connect(); + appServer.startsTerminalCommand({ + threadId: "thread-1", + turnId: "turn-1", + itemId: "interactive-shell", + processId: "73", + command, + }); + + const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1"); + appServer.typesIntoTerminal({ + threadId: "thread-1", + turnId: "turn-1", + itemId: "interactive-shell", + processId: "73", + text: "print(1)\n", + }); + + await expect(terminalWrite).resolves.toMatchObject({ + item: { + callId: "terminal-session-73-1", + name: "terminal", + detail: { type: "plain_text", label: "python3 -i", text: "print(1)\n" }, + metadata: { processId: "73" }, + }, + }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }, + ); + test("keeps repeated writes to one terminal as separate timeline rows", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( @@ -1667,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({ @@ -1934,6 +2087,66 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => { + const appServer = createFakeCodexAppServer({ + initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }), + "thread/read": () => ({ + thread: { id: "thread-1", historyMode: "legacy", turns: [] }, + }), + "thread/rollback": () => ({ + __jsonRpcError: { + code: -32600, + message: "Invalid request: unknown variant `thread/rollback`", + }, + }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await session.startTurn("remember first"); + emitCodexUserMessage(appServer, { + id: "codex-first", + text: "remember first", + turnId: "turn-first", + }); + appServer.completeTurn(); + await session.startTurn("remember second"); + emitCodexUserMessage(appServer, { + id: "codex-second", + text: "remember second", + turnId: "turn-second", + }); + appServer.completeTurn(); + + await session.revertConversation({ messageId: "codex-second" }); + + const forkRequests = appServer + .requests() + .filter((request) => request.method === "thread/fork") + .map((request) => request.params); + expect(forkRequests).toEqual([ + { + threadId: "thread-1", + beforeTurnId: "turn-second", + cwd: "/workspace/project", + model: "gpt-5.4", + serviceTier: null, + excludeTurns: false, + persistExtendedHistory: true, + }, + ]); + expect(appServer.recordedRollbacks).toEqual([]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + sessionId: "forked-thread", + }); + appServer.assertNoErrors(); + await session.close(); + }); + test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ "thread/read": () => ({ @@ -2199,7 +2412,12 @@ describe("Codex app-server provider", () => { throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`); } - expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]); + expect(threadRequests).toEqual([ + "model/list", + "config/read", + "thread/loaded/list", + "thread/resume", + ]); expect(outcome).toBe("rejected"); appServer.assertNoErrors(); }); @@ -2373,6 +2591,71 @@ describe("Codex app-server provider", () => { }); }); + test("keeps an output field named properties as a field", () => { + const input = { + type: "object", + properties: { properties: { type: "string" } }, + required: ["properties"], + additionalProperties: false, + }; + + expect(normalizeCodexOutputSchema(input)).toEqual(input); + }); + + test("normalizes schemas under keywords and leaves literal values alone", () => { + const literal = { type: "object", properties: { type: "string" } }; + const input = { + type: "object", + properties: { + result: { $ref: "#/$defs/result" }, + choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] }, + }, + $defs: { + properties: { type: "string" }, + result: { + type: "object", + properties: { value: { type: "string" } }, + default: literal, + examples: [literal], + enum: [literal], + const: literal, + }, + }, + }; + + expect(normalizeCodexOutputSchema(input)).toEqual({ + type: "object", + properties: { + result: { $ref: "#/$defs/result" }, + choice: { + anyOf: [ + { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + ], + }, + }, + required: ["result", "choice"], + additionalProperties: false, + $defs: { + properties: { type: "string" }, + result: { + type: "object", + properties: { value: { type: "string" } }, + default: literal, + examples: [literal], + enum: [literal], + const: literal, + required: ["value"], + additionalProperties: false, + }, + }, + }); + }); + test("passes a normalized output schema to turn/start", async () => { const session = createSession(); const request = vi.fn(async (method: string) => { @@ -5937,6 +6220,107 @@ describe("Codex app-server provider", () => { ]); }); + test.each([ + [ + "a usage limit", + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + ], + ["no failure details", null, { message: "Image generation failed" }], + ])( + "emits failed imageGeneration items with %s as failed tool calls", + (_label, failure, expectedError) => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-failed", + type: "imageGeneration", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure, + savedPath: null, + }, + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: expectedError, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }, + ); + + test("keeps failed imageGeneration items in persisted history", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async () => ({ + thread: { + turns: [ + { + items: [ + { + type: "imageGeneration", + id: "image-generation-failed", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + savedPath: null, + }, + ], + }, + ], + }, + })), + }; + + await asInternals(session).loadPersistedHistory(session.client); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }); + test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { const session = createSession(); const events: AgentStreamEvent[] = []; 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 1d0aca552c..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 @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, type AgentPermissionAction, @@ -56,12 +57,12 @@ import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async import { mapCodexToolCallEnvelope, mapCodexToolCallFromThreadItem, + normalizeCommandExecutionCommand, splitCodexMcpToolResultImages, } from "./codex/tool-call-mapper.js"; import { checkProviderLaunchAvailable, createProviderEnv, - createProviderEnvSpec, resolveProviderLaunch, type ProviderRuntimeSettings, type ResolvedProviderLaunch, @@ -73,7 +74,12 @@ import { import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; -import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + buildCodexFeatures, + CodexServiceTierSchema, + readCodexServiceTier, + type CodexServiceTier, +} from "./codex-feature-definitions.js"; import { CodexAppServerClient, CodexAppServerRpcError, @@ -169,6 +175,9 @@ const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = // (and the /goal slash command) when the binary is too old. const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0]; const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0]; +// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there +// has to fork before the target turn instead. +const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 0]; function parseCodexVersion(versionOutput: string): [number, number, number] | null { const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/); @@ -189,6 +198,15 @@ function codexVersionAtLeast( return true; } +// The initialize userAgent starts with `/`. An agent we +// cannot parse is treated as an older server that still has rollback. +function codexServerHasThreadRollback(userAgent: unknown): boolean { + return ( + typeof userAgent !== "string" || + !codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION) + ); +} + type GoalSubcommand = | { kind: "set"; objective: string } | { kind: "pause" } @@ -253,6 +271,7 @@ interface CodexAppServerClientLike { } interface CodexAppServerAgentDeps { + environment?: Record; workspaceGitService?: Pick; customProvider?: { id: string; @@ -365,19 +384,67 @@ function isObjectSchemaNode(schema: Record): boolean { ); } -function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown { +// Keywords whose value maps names to schemas. +const SCHEMA_MAP_KEYWORDS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", +] as const; + +// Keywords whose value is a schema or an array of schemas. +const SUBSCHEMA_KEYWORDS = [ + "items", + "prefixItems", + "additionalItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", +] as const; + +function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown { if (Array.isArray(schema)) { return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`), ); } + return normalizeCodexOutputSchemaNode(schema, schemaPath); +} + +function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown { if (!isSchemaRecord(schema)) { return schema; } - const normalized: Record = {}; - for (const [key, value] of Object.entries(schema)) { - normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`); + const normalized: Record = { ...schema }; + for (const keyword of SCHEMA_MAP_KEYWORDS) { + const schemas = schema[keyword]; + if (isSchemaRecord(schemas)) { + normalized[keyword] = Object.fromEntries( + Object.entries(schemas).map(([name, child]) => [ + name, + normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`), + ]), + ); + } + } + for (const keyword of SUBSCHEMA_KEYWORDS) { + if (keyword in schema) { + normalized[keyword] = normalizeCodexOutputSubschema( + schema[keyword], + `${schemaPath}.${keyword}`, + ); + } } if (!isObjectSchemaNode(normalized)) { @@ -881,6 +948,7 @@ interface CodexModel { model?: string; defaultReasoningEffort?: string; supportedReasoningEfforts?: CodexReasoningEffortEntry[]; + serviceTiers?: CodexServiceTier[]; } const CodexModelListResponseSchema = z.object({ @@ -888,6 +956,7 @@ const CodexModelListResponseSchema = z.object({ .array( z.object({ id: z.string(), + serviceTiers: z.array(CodexServiceTierSchema).optional(), displayName: z.string().optional(), description: z.string().optional(), isDefault: z.boolean().optional(), @@ -1880,6 +1949,15 @@ function mapCodexThreadImageItem( }); } + if (normalizedItem.status === "failed") { + return mapCodexToolCallEnvelope({ + callId: firstStringField(normalizedItem, ["id"]), + name: "image_generation", + input: { prompt: firstStringField(normalizedItem, ["revisedPrompt", "revised_prompt"]) }, + error: normalizedItem.failure ?? { message: "Image generation failed" }, + }); + } + const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); return renderProviderImageOutputAsAssistantMarkdown( @@ -3245,7 +3323,7 @@ function toCodexTextInput(text: string): Extract, -): NodeJS.ProcessEnv { +) { return createProviderEnv({ runtimeSettings, overlays: [launchEnv], @@ -3373,12 +3451,14 @@ export class CodexAppServerAgentSession implements AgentSession { cancelRequested: boolean; } | null = null; private client: CodexAppServerClient | null = null; + private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private activeClientMessageId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; - private serviceTier: "fast" | null = null; + private serviceTier: string | null = null; + private speedModels: CodexModel[] = []; private planModeEnabled = false; private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; @@ -3441,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, @@ -3452,7 +3545,6 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, private readonly initialResumePurpose: "interactive" | "history" = "interactive", - private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { this.logger = logger.child({ module: "agent", @@ -3464,14 +3556,14 @@ export class CodexAppServerAgentSession implements AgentSession { } this.hasWorkflowModeOverride = config.modeId !== undefined; this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; - this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); + 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); - if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = "fast"; - } + this.serviceTier = readCodexServiceTier(this.config.featureValues); if (this.config.featureValues?.plan_mode) { this.planModeEnabled = true; } @@ -3482,25 +3574,14 @@ export class CodexAppServerAgentSession implements AgentSession { } } - async getUsageReference() { - if (this.usageEnv.OPENAI_BASE_URL) return null; - return { - source: "codex", - input: { - codexHome: - this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"), - }, - }; - } - get id(): string | null { return this.currentThreadId; } get features(): AgentFeature[] { return buildCodexFeatures({ - modelId: this.config.model, - fastModeEnabled: this.serviceTier === "fast", + serviceTiers: this.currentServiceTiers(), + serviceTier: this.serviceTier ?? "default", planModeEnabled: this.planModeEnabled, planModeAvailable: this.hasPlanCollaborationMode(), }); @@ -3553,9 +3634,15 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerRequestHandlers(); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); + const initialized = toObjectRecord( + await client.request("initialize", buildCodexAppServerInitializeParams()), + ); + this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent); client.notify("initialized", {}); + this.speedModels = + CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; + this.reconcileServiceTier(); await this.loadResolvedWorkspaceWrite(); await this.loadCollaborationModes(); await this.loadSkills(); @@ -3563,6 +3650,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.currentThreadId) { await this.ensureThreadLoaded(); await this.loadPersistedHistory(this.client); + await this.applyDefaultModelAndThinking(); } if (this.closed) { @@ -3759,18 +3847,30 @@ export class CodexAppServerAgentSession implements AgentSession { this.resolvedCollaborationMode = this.resolveCollaborationMode(); } - private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void { - this.config.featureValues = { - ...this.config.featureValues, - [featureId]: value, - }; + private currentServiceTiers(): CodexServiceTier[] { + const selectedModel = this.config.model + ? this.speedModels.find( + (model) => model.id === this.config.model || model.model === this.config.model, + ) + : (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]); + return selectedModel?.serviceTiers ?? []; + } - if (featureId === "fast_mode") { - this.serviceTier = value ? "fast" : null; - this.cachedRuntimeInfo = null; - return; + private reconcileServiceTier(): void { + const tiers = this.currentServiceTiers(); + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) { + this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null; } + if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) { + this.serviceTier = null; + } + const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {}; + this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" }; + } + private applyFeatureValue(featureId: "plan_mode", value: boolean): void { + this.config.featureValues = { ...this.config.featureValues, [featureId]: value }; this.planModeEnabled = value; this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; @@ -4101,9 +4201,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (thinkingOptionId) { params.effort = thinkingOptionId; } - if (this.serviceTier) { - params.serviceTier = this.serviceTier; - } + params.serviceTier = this.serviceTier ?? "default"; if (this.resolvedCollaborationMode) { params.collaborationMode = { mode: this.resolvedCollaborationMode.mode, @@ -4521,9 +4619,7 @@ export class CodexAppServerAgentSession implements AgentSession { async setModel(modelId: string | null): Promise { this.config.model = modelId ?? undefined; - if (!codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = null; - } + this.reconcileServiceTier(); this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } @@ -4538,13 +4634,27 @@ export class CodexAppServerAgentSession implements AgentSession { } async setFeature(featureId: string, value: unknown): Promise { + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. if (featureId === "fast_mode") { - if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) { + const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast"); + if (value && !fast) throw new Error( `Codex fast mode is not available for model '${this.config.model ?? "default"}'`, ); + return this.setFeature("service_tier", value ? fast!.id : "default"); + } + if (featureId === "service_tier") { + if ( + typeof value !== "string" || + (value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value)) + ) { + throw new Error( + `Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`, + ); } - this.applyFeatureValue("fast_mode", Boolean(value)); + this.serviceTier = value; + this.reconcileServiceTier(); + this.cachedRuntimeInfo = null; return; } if (featureId === "plan_mode") { @@ -4818,7 +4928,6 @@ export class CodexAppServerAgentSession implements AgentSession { modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, - providerOptions: this.config.providerOptions, toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, @@ -4847,6 +4956,7 @@ export class CodexAppServerAgentSession implements AgentSession { serviceTier: this.serviceTier, config: this.buildCodexInnerConfig(), userMessageTurns: this.codexUserMessageTurns(), + threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; this.cachedRuntimeInfo = null; @@ -5160,13 +5270,21 @@ export class CodexAppServerAgentSession implements AgentSession { return { model, thinkingOptionId }; } + // A session without a model or effort runs on Codex's configured defaults, whether its + // thread is new or resumed after a restart. + private async applyDefaultModelAndThinking(): Promise { + const { model, thinkingOptionId } = await this.resolveModelAndThinking(); + this.config.model = model; + this.reconcileServiceTier(); + this.config.thinkingOptionId = thinkingOptionId; + return model; + } + private async ensureThread(): Promise { if (!this.client) return; if (this.currentThreadId) return; - const { model, thinkingOptionId } = await this.resolveModelAndThinking(); - this.config.model = model; - this.config.thinkingOptionId = thinkingOptionId; + const model = await this.applyDefaultModelAndThinking(); const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model); const rawResponse = await this.client.request("thread/start", params); @@ -6637,6 +6755,7 @@ export class CodexAppServerAgentSession implements AgentSession { ); const itemId = parsed.item.id; if (normalizedItemType === "commandExecution") { + this.rememberTerminalProcessForItem(parsed.item); const callId = timelineItem.callId || itemId; if (callId && this.emittedExecCommandStartedCallIds.has(callId)) { return; @@ -6756,19 +6875,21 @@ export class CodexAppServerAgentSession implements AgentSession { } private rememberTerminalProcessForCommand(command: unknown, output: string | null): void { - const normalizedCommand = normalizeCodexCommandValue(command); - if (!normalizedCommand) { - return; - } - const displayCommand = - typeof normalizedCommand === "string" - ? normalizedCommand - : normalizedCommand.join(" ").trim(); - if (!displayCommand) { - return; - } const processId = extractCodexTerminalSessionId(output ?? undefined); - if (!processId) { + if (processId) { + this.rememberTerminalProcess(processId, command); + } + } + + private rememberTerminalProcessForItem(item: { [key: string]: unknown }): void { + if (typeof item.processId === "string" && item.processId) { + this.rememberTerminalProcess(item.processId, item.command); + } + } + + private rememberTerminalProcess(processId: string, command: unknown): void { + const displayCommand = normalizeCommandExecutionCommand(command); + if (!displayCommand) { return; } this.terminalCommandByProcessId.set(processId, displayCommand); @@ -6840,6 +6961,8 @@ export class CodexAppServerAgentSession implements AgentSession { const parsed = z .object({ itemId: z.string(), + // Set when several approval callbacks belong to one item; it names the callback. + approvalId: z.string().nullable().optional(), threadId: z.string(), turnId: z.string(), command: z.string().nullable().optional(), @@ -6853,7 +6976,7 @@ export class CodexAppServerAgentSession implements AgentSession { cwd: parsed.cwd ?? this.config.cwd ?? null, running: true, }); - const requestId = `permission-${parsed.itemId}`; + const requestId = `permission-${parsed.approvalId ?? parsed.itemId}`; const title = parsed.command ? `Run command: ${parsed.command}` : "Run command"; const request: AgentPermissionRequest = { id: requestId, @@ -7102,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"]; @@ -7121,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; @@ -7145,19 +7265,23 @@ 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, launchContext?.agentId, "interactive", - buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; @@ -7178,19 +7302,23 @@ 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, launchContext?.agentId, options?.purpose ?? "interactive", - buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index d2588a5a5f..5eb11d8a0c 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -1,27 +1,33 @@ import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; -// Codex Fast is distinct from API Priority processing. Keep model support aligned with -// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models. -const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([ - "gpt-6-astra", - "gpt-6-sol", - "gpt-6-luna", - "gpt-5.6", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4", -]); +import { z } from "zod"; -export const CODEX_FAST_MODE_FEATURE: Omit = { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", -}; +export const CodexServiceTierSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), +}); +export type CodexServiceTier = z.infer; + +function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] { + if (tiers.length === 0) return []; + return [ + { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + ...tiers.map((tier) => ({ id: tier.id, label: tier.name })), + ], + }, + ]; +} export const CODEX_PLAN_MODE_FEATURE: Omit = { type: "toggle", @@ -32,40 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit = { icon: "list-todo", }; -function normalizeCodexModelId(modelId: string | null | undefined): string | null { - const normalized = typeof modelId === "string" ? modelId.trim() : ""; - return normalized.length > 0 ? normalized : null; -} - -export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { - const normalizedModelId = normalizeCodexModelId(modelId); - if (!normalizedModelId) { - return false; - } - return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId); -} - export function buildCodexFeatures(input: { - modelId: string | null | undefined; - fastModeEnabled: boolean; + serviceTiers: CodexServiceTier[]; + serviceTier: string; planModeEnabled: boolean; planModeAvailable?: boolean; }): AgentFeature[] { - const features: AgentFeature[] = []; - - if (codexModelSupportsFastMode(input.modelId)) { - features.push({ - ...CODEX_FAST_MODE_FEATURE, - value: input.fastModeEnabled, - }); - } - + const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier); if (input.planModeAvailable !== false) { - features.push({ - ...CODEX_PLAN_MODE_FEATURE, - value: input.planModeEnabled, - }); + features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled }); } - return features; } + +export function readCodexServiceTier(values: Record | undefined): string | null { + const tier = values?.service_tier; + if (typeof tier === "string") return tier; + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + return values?.fast_mode ? "fast" : null; +} diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts index 923b8074bb..da7fe6a8f5 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.test.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -96,6 +96,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -131,6 +132,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "codex-second", userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -175,6 +177,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -218,6 +221,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -239,6 +243,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "missing-message", userMessageTurns, + threadRollbackAvailable: true, setThreadId: () => undefined, }), ).rejects.toThrow("Codex could not find user message missing-message"); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts index 1c3d3c471c..021d57645c 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -69,6 +69,7 @@ export async function revertCodexConversation(input: { serviceTier?: string | null; config?: Record | null; userMessageTurns: CodexUserMessageTurnIndex; + threadRollbackAvailable: boolean; setThreadId: (threadId: string) => void | Promise; }): Promise { if (!input.threadId) { @@ -98,8 +99,10 @@ export async function revertCodexConversation(input: { persistExtendedHistory: true, }; - const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId); - if (historyMode === "paginated") { + if ( + !input.threadRollbackAvailable || + (await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated" + ) { if (!targetTurn.turnId) { throw new Error(`Codex could not find the turn containing user message ${input.messageId}`); } @@ -111,7 +114,8 @@ export async function revertCodexConversation(input: { return; } - // Fork is non-destructive: the old thread file stays on disk and remains + // Legacy threads on Codex before 0.156 fork and then roll back. Fork is + // non-destructive: the old thread file stays on disk and remains // recoverable with `codex resume ` if the rewind target was wrong. const forked = await forkCodexThread(input.client, forkParams); const forkedThreadId = forked.thread.id; diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 31e231cdb1..226eeb9738 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -25,6 +25,13 @@ interface FakeSilentCommand { command: string; cwd: string; } +interface FakeTerminalCommand { + threadId: string; + turnId: string; + itemId: string; + processId: string; + command: string; +} interface FakeTerminalInput { threadId: string; turnId: string; @@ -81,17 +88,20 @@ export interface FakeCodexAppServer { completesCommand(params: FakeLegacyCommand): void; completesSilentCommand(params: FakeSilentCommand): void; completesSilentLegacyCommand(params: FakeSilentCommand): void; + startsTerminalCommand(params: FakeTerminalCommand): void; typesIntoTerminal(params: FakeTerminalInput): void; says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void; requestCommandApproval(params: { itemId: string; + approvalId?: string; threadId: string; turnId: string; command: string; cwd: string; reason: string; }): void; - waitForCommandApprovalDecision(itemId: string): Promise; + /** Keyed by `approvalId` when the request carried one, otherwise by `itemId`. */ + waitForCommandApprovalDecision(callbackId: string): Promise; requestFileChangeApproval(params: { itemId: string; threadId: string; @@ -485,6 +495,19 @@ export function createFakeCodexAppServer( success: true, }); }, + startsTerminalCommand(params) { + writeNotification("item/started", { + threadId: params.threadId, + turnId: params.turnId, + item: { + type: "commandExecution", + id: params.itemId, + status: "inProgress", + command: params.command, + processId: params.processId, + }, + }); + }, typesIntoTerminal(params) { writeNotification("item/commandExecution/terminalInteraction", { threadId: params.threadId, @@ -518,7 +541,7 @@ export function createFakeCodexAppServer( requestCommandApproval(params) { const requestId = nextServerRequestId; nextServerRequestId += 1; - approvalRequestIds.set(params.itemId, requestId); + approvalRequestIds.set(params.approvalId ?? params.itemId, requestId); child.stdout.write( `${JSON.stringify({ jsonrpc: "2.0", @@ -528,8 +551,8 @@ export function createFakeCodexAppServer( })}\n`, ); }, - async waitForCommandApprovalDecision(itemId) { - return await this.waitForApprovalDecision(itemId); + async waitForCommandApprovalDecision(callbackId) { + return await this.waitForApprovalDecision(callbackId); }, requestFileChangeApproval(params) { const requestId = nextServerRequestId; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6ce2d4fe13..566fd10083 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -656,6 +656,29 @@ describe("codex tool-call mapper", () => { }); }); + it.each([ + { + type: "commandExecution", + id: "codex-declined-command", + status: "declined", + command: "printf ok > /outside/permission.txt", + cwd: "/tmp/repo", + aggregatedOutput: null, + exitCode: null, + }, + { + type: "fileChange", + id: "codex-declined-patch", + status: "declined", + changes: [], + }, + ])("settles a declined $type as failed instead of running", (threadItem) => { + const item = expectMapped(mapCodexToolCallFromThreadItem(threadItem)); + + expect(item.callId).toBe(threadItem.id); + expect(item.status).toBe("failed"); + }); + it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( mapCodexToolCallEnvelope({ diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 2f274d2d3f..64629bc5d9 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -245,7 +245,7 @@ function isWindowsShellCommand(command: string): boolean { return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized); } -function normalizeCommandExecutionCommand(value: unknown): string | undefined { +export function normalizeCommandExecutionCommand(value: unknown): string | undefined { if (typeof value === "string") { const normalized = unwrapShellCommand(value); return normalized.length > 0 ? normalized : undefined; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 0a7ce428b3..0b5b9ac4ec 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -16,7 +16,6 @@ interface CursorACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -93,7 +92,6 @@ export class CursorACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // cursor-agent publishes slash commands asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index b37e1a9a54..d70117d57d 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -1,3 +1,4 @@ +import { buildProviderRegistry } from "../provider-registry.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -254,22 +255,28 @@ describe("GenericACPAgentClient diagnostics", () => { test("sends configured client capabilities in catalog and live session initialization", async () => { await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { const initializeTracePath = path.join(testDir, "initialize.jsonl"); - const client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: [process.execPath, scriptPath, mode, "", initializeTracePath], - providerParams: { - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "configured-acp": { + extends: "acp", + label: "Configured ACP", + command: [process.execPath, scriptPath, mode, "", initializeTracePath], + options: { + supportsMcpServers: false, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, }, - terminal: true, }, }, }); + const client = registry["configured-acp"].createClient(logger); await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true }); - const session = await client.createSession({ provider: "acp", cwd: testDir }); + const session = await client.createSession({ provider: "configured-acp", cwd: testDir }); + expect(session.capabilities.supportsMcpServers).toBe(false); await session.close(); const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts new file mode 100644 index 0000000000..9e652a37d5 --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts @@ -0,0 +1,121 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient request errors", () => { + test("reports the agent's message when it rejects the configured mode", async () => { + await withFakeACPAgent("reject-set-mode", async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const created = client.createSession({ provider: "acp", cwd, modeId: "plan" }); + + await expect(created).rejects.toBeInstanceOf(Error); + await expect(created).rejects.toThrow("Internal error: Mode plan is not allowed"); + }); + }); + + test("reports the agent's message when it rejects the configured thinking option", async () => { + await withFakeACPAgent("reject-set-config-option", async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const created = client.createSession({ provider: "acp", cwd, thinkingOptionId: "high" }); + + await expect(created).rejects.toBeInstanceOf(Error); + await expect(created).rejects.toThrow("Internal error: Effort high is not allowed"); + }); + }); +}); + +async function withFakeACPAgent( + mode: "reject-set-mode" | "reject-set-config-option", + run: (command: [string, ...string[]], cwd: string) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-request-errors-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run([process.execPath, scriptPath, mode], testDir); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const readline = require("node:readline"); + +const mode = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/new") { + write({ + id: message.id, + result: { + sessionId: "session-1", + modes: { + currentModeId: "agent", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "plan", name: "Plan" }, + ], + }, + configOptions: [ + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "low", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ], + }, + }); + return; + } + + if (message.method === "session/set_mode" && mode === "reject-set-mode") { + write({ + id: message.id, + error: { code: -32603, message: "Internal error", data: { details: "Mode plan is not allowed" } }, + }); + return; + } + + if (message.method === "session/set_config_option" && mode === "reject-set-config-option") { + write({ + id: message.id, + error: { code: -32603, message: "Internal error", data: { details: "Effort high is not allowed" } }, + }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts index c266cc148b..a1f5c497ec 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts @@ -66,21 +66,4 @@ describe("GenericACPAgentClient", () => { }, ]); }); - - test("uses provider params to report MCP support", () => { - const _client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: ["no-mcp-acp", "serve"], - providerParams: { - supportsMcpServers: false, - }, - }); - void _client; - - expect(mockState.superConstructorOptions.at(-1)).toMatchObject({ - capabilities: { - supportsMcpServers: false, - }, - }); - }); }); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index c8f8960ea2..5a14d99137 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,7 +1,5 @@ import type { Logger } from "pino"; -import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, @@ -18,32 +16,12 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; -export const GenericACPProviderParamsSchema = z - .object({ - supportsMcpServers: z.boolean().optional(), - clientCapabilities: z - .object({ - fs: z - .object({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - }) - .optional(), - terminal: z.boolean().optional(), - }) - .optional(), - }) - .passthrough(); - -type GenericACPProviderParams = z.infer; - interface GenericACPAgentClientOptions { logger: Logger; command: [string, ...string[]]; env?: Record; providerId?: string; label?: string; - providerParams?: unknown; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number; @@ -61,7 +39,6 @@ export class GenericACPAgentClient extends ACPAgentClient { private readonly diagnosticPhaseTimeoutMs?: number; constructor(options: GenericACPAgentClientOptions) { - const providerParams = parseGenericACPProviderParams(options.providerParams); super({ provider: "acp", logger: options.logger, @@ -69,12 +46,11 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, - capabilities: buildGenericACPCapabilities(providerParams), + capabilities: DEFAULT_ACP_CAPABILITIES, // ACP agents advertise slash commands with available_commands_update after // session/new, so the first listCommands() waits for that batch. waitForInitialCommands: options.waitForInitialCommands ?? true, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, - clientCapabilities: providerParams.clientCapabilities, clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, @@ -165,17 +141,6 @@ export class GenericACPAgentClient extends ACPAgentClient { } } -function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags { - return { - ...DEFAULT_ACP_CAPABILITIES, - supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers, - }; -} - -function parseGenericACPProviderParams(params: unknown): GenericACPProviderParams { - return GenericACPProviderParamsSchema.parse(params ?? {}); -} - export interface CommandInvocation { command: string; args: string[]; diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts index 50effdf33a..7ae144f653 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -15,7 +15,6 @@ interface KimiACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } // Kimi exposes thinking options only for the selected model. Keep its model-switching @@ -88,7 +87,6 @@ export class KimiACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, catalogModelResolver: resolveKimiCatalogModels, }); } diff --git a/packages/server/src/server/agent/providers/kiro-acp-agent.ts b/packages/server/src/server/agent/providers/kiro-acp-agent.ts index 53fd7f2408..41bbd97647 100644 --- a/packages/server/src/server/agent/providers/kiro-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kiro-acp-agent.ts @@ -10,7 +10,6 @@ interface KiroACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } // Kiro CLI publishes its slash commands and skills asynchronously through the @@ -92,7 +91,6 @@ export class KiroACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, waitForInitialCommands: true, initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, extensionCommandsParser: parseKiroExtensionCommands, 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 6858ed216d..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,17 +1,15 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import path, { join } from "node:path"; +import path from "node:path"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; -import { resolveOmpProviderParams } from "./provider-config.js"; +import { resolveOmpProviderOptions } from "./provider-config.js"; import { OmpRuntimeEventSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpAgentClient } from "./agent.js"; @@ -42,11 +40,11 @@ const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = { }; test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => { - expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({ readyTimeoutMs: 20_000, rpcTimeoutMs: 60_000, }); - expect(resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({ rpcTimeoutMs: 90_000 }).runtimeOptions).toMatchObject({ readyTimeoutMs: 90_000, rpcTimeoutMs: 90_000, }); @@ -71,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 }> = []; @@ -172,79 +215,6 @@ function createToolCatalog(): PaseoToolCatalog { } describe("OMP agent client and session", () => { - test("usage reference reads current get_state provider and account on each call", async () => { - const home = mkdtempSync(join(tmpdir(), "paseo-omp-reference-")); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. - const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { - DatabaseSync: new (path: string) => { - exec(sql: string): void; - prepare(sql: string): { run(...args: unknown[]): void }; - close(): void; - }; - }; - const db = new DatabaseSync(join(agentDir, "agent.db")); - db.exec(` - CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); - CREATE INDEX idx_cache_expires ON cache(expires_at); - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), - updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) - ); - `); - const insert = db.prepare( - "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, 'oauth', ?)", - ); - insert.run( - "openai-codex", - JSON.stringify({ - access: "codex-fixture", - accountId: "codex-account", - expires: Date.now() + 60_000, - }), - ); - insert.run( - "anthropic", - JSON.stringify({ - access: "claude-fixture", - accountId: "claude-account", - expires: Date.now() + 60_000, - }), - ); - db.close(); - try { - const omp = new OmpHarness(); - await omp.start({}, undefined, { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: agentDir, - XDG_DATA_HOME: "", - }); - const runtime = omp.runtime(); - runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } }; - const requestsBefore = runtime.getStateRequestCount; - expect(await omp.getUsageReference()).toEqual({ - source: "codex", - input: { accessToken: "codex-fixture", accountId: "codex-account" }, - }); - expect(runtime.getStateRequestCount).toBe(requestsBefore + 1); - runtime.state = { ...runtime.state, model: { provider: "anthropic", id: "claude-model" } }; - expect(await omp.getUsageReference()).toEqual({ - source: "claude", - input: { accessToken: "claude-fixture" }, - }); - expect(runtime.getStateRequestCount).toBe(requestsBefore + 2); - await omp.close(); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); test("owns launch configuration and registers native host tools", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "ask" }, createToolCatalog()); @@ -1033,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 8e931b55fd..a04b4c8fb0 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -63,10 +63,9 @@ import { mergeOmpRuntimeSettings, resolveOmpDiagnosticPaths, resolveOmpLaunchMode, - resolveOmpProviderParams, + resolveOmpProviderOptions, OMP_MODES, - type OmpModelRoleParams, - type OmpRuntimeProviderParams, + type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; @@ -115,7 +114,6 @@ import { mapOmpRpcUiPermissionRequest, } from "./rpc-ui-permission-mapper.js"; import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; -import { resolveOmpUsageReference } from "./usage-reference.js"; const OMP_PROVIDER = "omp"; const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { @@ -134,7 +132,6 @@ const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: OmpRuntime; subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; @@ -201,7 +198,6 @@ interface OmpAgentSessionOptions { noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; providerIdleDeadlineMs?: number; - usageEnv?: NodeJS.ProcessEnv; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -449,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 } : {}), @@ -593,15 +588,15 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio function createRuntime( logger: Logger, runtimeSettings: ProviderRuntimeSettings | undefined, - providerParams: OmpRuntimeProviderParams, + providerOptions: OmpRuntimeOptions, ): OmpRuntime { return new OmpCliRuntime({ logger, runtimeSettings, command: ["omp"], commandsRpcName: "get_available_commands", - readyTimeoutMs: providerParams.readyTimeoutMs, - requestTimeoutMs: providerParams.rpcTimeoutMs, + readyTimeoutMs: providerOptions.readyTimeoutMs, + requestTimeoutMs: providerOptions.rpcTimeoutMs, }); } @@ -727,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; @@ -735,7 +743,6 @@ export class OmpAgentSession implements AgentSession { this.state = options.initialState; this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; - this.usageEnv = options.usageEnv ?? process.env; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS; @@ -785,12 +792,6 @@ export class OmpAgentSession implements AgentSession { private readonly restartRuntime: OmpAgentSessionOptions["restartRuntime"]; private readonly config: AgentSessionConfig; private readonly logger: Logger; - private readonly usageEnv: NodeJS.ProcessEnv; - - async getUsageReference() { - const state = await this.runtimeSession.getState(); - return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv); - } get id(): string | null { return this.state.sessionId; @@ -2210,19 +2211,14 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: OmpRuntimeProviderParams; - private readonly modelRoleParams: OmpModelRoleParams; private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; private readonly providerIdleDeadlineMs?: number; - private readonly runtime: OmpRuntime; + private readonly runtime?: OmpRuntime; constructor(options: OmpAgentClientOptions) { - const { runtimeProviderParams, modelRoleParams } = resolveOmpProviderParams( - options.providerParams, - ); const runtimeSettings = mergeOmpRuntimeSettings( { command: { @@ -2234,15 +2230,12 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.providerParams = runtimeProviderParams; - this.modelRoleParams = modelRoleParams; this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; this.providerIdleDeadlineMs = options.providerIdleDeadlineMs; - this.runtime = - options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams); + this.runtime = options.runtime; } private async configureNativePaseoTools( @@ -2285,7 +2278,7 @@ export class OmpAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { - const launchMode = this.resolveLaunchMode(config.modeId); + const launchMode = this.resolveLaunchMode(config.modeId, config.providerOptions); const startInput: OmpStartSessionInput = { cwd: config.cwd, protocolMode: "rpc-ui", @@ -2297,7 +2290,9 @@ export class OmpAgentClient implements AgentClient { systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), env: launchContext?.env, }; - const runtimeSession = await this.runtime.startSession(startInput); + const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession( + startInput, + ); let hostTools: OmpHostToolRouter | undefined; try { hostTools = await this.configureNativePaseoTools( @@ -2324,7 +2319,6 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, providerIdleDeadlineMs: this.providerIdleDeadlineMs, - usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, }); } catch (error) { await hostTools?.close(); @@ -2357,14 +2351,19 @@ export class OmpAgentClient implements AgentClient { ); } - const launchMode = this.resolveLaunchMode(resumeConfig.modeId); + const launchMode = this.resolveLaunchMode( + resumeConfig.modeId, + resumeConfig.config.providerOptions, + ); const startInput = buildResumeStartInput({ resumeConfig, sessionFile, launchContext, launchMode, }); - const runtimeSession = await this.runtime.startSession(startInput); + const runtimeSession = await this.resolveRuntime( + resumeConfig.config.providerOptions, + ).startSession(startInput); let hostTools: OmpHostToolRouter | undefined; try { hostTools = await this.configureNativePaseoTools( @@ -2373,16 +2372,17 @@ export class OmpAgentClient implements AgentClient { resumeConfig.config, startInput.env, ); - const initialState = await this.restoreFastMode( - runtimeSession, - resumeConfig.config, - await runtimeSession.getState(), - ); + const resumedState = await this.applyResumeModel(runtimeSession, resumeConfig.model); + const config = { + ...resumeConfig.config, + model: modelToId(resumedState.model) ?? resumeConfig.config.model, + }; + const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ runtimeSession, hostTools, - restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext), - config: resumeConfig.config, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, initialState, currentModeId: launchMode.modeId, logger: this.logger, @@ -2391,7 +2391,6 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, providerIdleDeadlineMs: this.providerIdleDeadlineMs, - usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, live: false, }); } catch (error) { @@ -2401,14 +2400,43 @@ 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, launchContext?: AgentLaunchContext, ): OmpAgentSessionOptions["restartRuntime"] { return async (sessionFile, modeId) => { - const launchMode = this.resolveLaunchMode(modeId); - const next = await this.runtime.startSession({ + const launchMode = this.resolveLaunchMode(modeId, config.providerOptions); + const next = await this.resolveRuntime(config.providerOptions).startSession({ ...startInput, model: config.model, thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, @@ -2439,7 +2467,7 @@ export class OmpAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { - const launchMode = this.resolveLaunchMode(undefined); + const launchMode = this.resolveLaunchMode(undefined, options.providerOptions); let runtimeSession: OmpRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2451,7 +2479,7 @@ export class OmpAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await this.resolveRuntime(options.providerOptions).startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, protocolMode: "rpc-ui", modeId: launchMode.modeId, @@ -2485,14 +2513,15 @@ export class OmpAgentClient implements AgentClient { ): Promise { return await listOmpImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: resolveOmpProviderOptions(options?.providerOptions).runtimeOptions.sessionDir, runtimeSettings: this.runtimeSettings, }); } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { const descriptorOptions = { - sessionDir: this.providerParams.sessionDir, + sessionDir: resolveOmpProviderOptions(context.config.providerOptions).runtimeOptions + .sessionDir, runtimeSettings: this.runtimeSettings, }; const importConfig = await readOmpImportSessionConfig( @@ -2567,11 +2596,19 @@ export class OmpAgentClient implements AgentClient { } } - private resolveLaunchMode(modeId: string | undefined): { + private resolveLaunchMode( + modeId: string | undefined, + providerOptions?: Record, + ): { modeId: string; extraArgs: string[]; } { - return resolveOmpLaunchMode(modeId, this.modelRoleParams); + return resolveOmpLaunchMode(modeId, resolveOmpProviderOptions(providerOptions).modelRoles); + } + + private resolveRuntime(providerOptions: Record | undefined): OmpRuntime { + const { runtimeOptions } = resolveOmpProviderOptions(providerOptions); + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, runtimeOptions); } private async resolveOmpLaunch(): Promise { 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/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 2fe5067845..0bcd369a1c 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -13,7 +13,7 @@ const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000; export const MIN_SUPPORTED_OMP_VERSION = "16.3.9"; export { OMP_MODES }; -export const OmpProviderParamsSchema = z +export const OmpProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().optional(), @@ -23,13 +23,13 @@ export const OmpProviderParamsSchema = z }) .strict(); -export interface OmpRuntimeProviderParams { +export interface OmpRuntimeOptions { sessionDir?: string; readyTimeoutMs: number; rpcTimeoutMs: number; } -export interface OmpModelRoleParams { +export interface OmpModelRoles { smolModel?: string; slowModel?: string; planModel?: string; @@ -37,9 +37,9 @@ export interface OmpModelRoleParams { export function resolveOmpLaunchMode( modeId: string | undefined, - modelRoleParams: OmpModelRoleParams = {}, + modelRoles: OmpModelRoles = {}, ): { modeId: string; extraArgs: string[] } { - const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams); + const modelRoleArgs = resolveOmpModelRoleArgs(modelRoles); switch (modeId ?? DEFAULT_OMP_MODE_ID) { case "full": return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] }; @@ -58,11 +58,11 @@ export function resolveOmpLaunchMode( } } -function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] { +function resolveOmpModelRoleArgs(modelRoles: OmpModelRoles): string[] { const args: string[] = []; - if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel); - if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel); - if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel); + if (modelRoles.smolModel) args.push("--smol", modelRoles.smolModel); + if (modelRoles.slowModel) args.push("--slow", modelRoles.slowModel); + if (modelRoles.planModel) args.push("--plan", modelRoles.planModel); return args; } @@ -128,22 +128,22 @@ export function formatOmpVersionSupport(versionOutput: string): string { return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`; } -export function resolveOmpProviderParams(providerParams: unknown): { - runtimeProviderParams: OmpRuntimeProviderParams; - modelRoleParams: OmpModelRoleParams; +export function resolveOmpProviderOptions(providerOptions: unknown): { + runtimeOptions: OmpRuntimeOptions; + modelRoles: OmpModelRoles; } { - const params = OmpProviderParamsSchema.parse(providerParams ?? {}); - const configuredRpcTimeoutMs = params.rpcTimeoutMs; + const options = OmpProviderOptionsSchema.parse(providerOptions ?? {}); + const configuredRpcTimeoutMs = options.rpcTimeoutMs; return { - runtimeProviderParams: { - sessionDir: params.sessionDir, + runtimeOptions: { + sessionDir: options.sessionDir, readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS, rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS, }, - modelRoleParams: { - ...(params.smolModel ? { smolModel: params.smolModel } : {}), - ...(params.slowModel ? { slowModel: params.slowModel } : {}), - ...(params.planModel ? { planModel: params.planModel } : {}), + modelRoles: { + ...(options.smolModel ? { smolModel: options.smolModel } : {}), + ...(options.slowModel ? { slowModel: options.slowModel } : {}), + ...(options.planModel ? { planModel: options.planModel } : {}), }, }; } 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 5c53dd3e4f..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 @@ -540,10 +540,6 @@ export class OmpHarness { return this.omp.latestSession(); } - async getUsageReference() { - return this.requireSession().getUsageReference(); - } - runningToolCallIds(): string[] { const statusByCall = new Map(); for (const item of this.timeline()) { @@ -570,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/omp/usage-reference.test.ts b/packages/server/src/server/agent/providers/omp/usage-reference.test.ts deleted file mode 100644 index 659c0afcea..0000000000 --- a/packages/server/src/server/agent/providers/omp/usage-reference.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createRequire } from "node:module"; -import { afterEach, expect, test } from "vitest"; -import { resolveOmpUsageReference } from "./usage-reference.js"; - -const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { - DatabaseSync: new (path: string) => { - exec(sql: string): void; - prepare(sql: string): { run(...args: unknown[]): void }; - close(): void; - }; -}; -// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. -const OMP_AUTH_SCHEMA = ` - CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); - CREATE INDEX idx_cache_expires ON cache(expires_at); - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), - updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) - ); -`; -const dirs: string[] = []; -const future = Date.now() + 3_600_000; -function fixture() { - const home = mkdtempSync(join(tmpdir(), "omp-usage-")); - dirs.push(home); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - const dbFile = join(agentDir, "agent.db"); - const db = new DatabaseSync(dbFile); - db.exec(OMP_AUTH_SCHEMA); - const env = { OMP_PROFILE: "", PI_CODING_AGENT_DIR: agentDir, XDG_DATA_HOME: "" }; - const row = ( - id: number, - provider = "openai-codex", - options: { disabled?: boolean; expires?: number; type?: string } = {}, - ) => - db - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run( - id, - provider, - options.type ?? "oauth", - JSON.stringify({ - access: `fixture-token-${id}`, - expires: options.expires ?? future, - accountId: `account-${id}`, - }), - options.disabled ? "disabled" : null, - ); - const sticky = (id: number, expires = Math.floor(Date.now() / 1000) + 100) => - db - .prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)") - .run( - "session:sticky:openai-codex:session-1", - JSON.stringify({ type: "oauth", index: 0, credentialId: id }), - expires, - ); - const read = (override = {}) => - resolveOmpUsageReference("session-1", "openai-codex", { ...env, ...override }); - return { db, dbFile, agentDir, row, sticky, read }; -} -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); -}); -test("sticky id selects the right OAuth row and leaves SQLite byte-identical", () => { - const f = fixture(); - f.row(1); - f.row(2); - f.sticky(2); - f.db.close(); - const before = readFileSync(f.dbFile); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-2", accountId: "account-2" }, - }); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-2", accountId: "account-2" }, - }); - expect(readFileSync(f.dbFile)).toEqual(before); -}); -test("single OAuth row resolves without sticky", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); -}); -test("multiple rows without sticky are ambiguous", () => { - const f = fixture(); - f.row(1); - f.row(2); - f.db.close(); - expect(f.read()).toBeNull(); -}); -test("fallback counts only valid OAuth rows", () => { - const f = fixture(); - f.row(1); - f.row(2, "openai-codex", { expires: Date.now() - 1 }); - f.row(3, "openai-codex", { disabled: true }); - f.db - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run(4, "openai-codex", "oauth", "invalid-json", null); - f.db.close(); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); -}); -test("disabled and expired rows do not resolve", () => { - const a = fixture(); - a.row(1, "openai-codex", { disabled: true }); - a.sticky(1); - a.db.close(); - expect(a.read()).toBeNull(); - const b = fixture(); - b.row(1, "openai-codex", { expires: Date.now() - 1 }); - b.db.close(); - expect(b.read()).toBeNull(); -}); -test("expired sticky falls back to a single row", () => { - const f = fixture(); - f.row(1); - f.sticky(1, 1); - f.db.close(); - expect(f.read()?.source).toBe("codex"); -}); -test("env key does not displace OAuth with or without sticky", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); - const g = fixture(); - g.row(1); - g.sticky(1); - g.db.close(); - expect(g.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); -}); -test("broker environment and config URLs block attribution", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read({ OMP_AUTH_BROKER_URL: "https://broker.invalid" })).toBeNull(); - writeFileSync( - join(f.agentDir, "config.yml"), - "auth:\n broker:\n url: https://broker.invalid\n", - ); - expect(f.read()).toBeNull(); -}); -test("broker config.yaml and flat keys block attribution with config.yml precedence", () => { - const f = fixture(); - f.row(1); - f.db.close(); - writeFileSync(join(f.agentDir, "config.yaml"), '"auth.broker.url": https://broker.invalid\n'); - expect(f.read()).toBeNull(); - writeFileSync(join(f.agentDir, "config.yml"), "auth:\n broker: {}\n"); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); - writeFileSync(join(f.agentDir, "config.yml"), '"auth.broker.url": https://broker.invalid\n'); - expect(f.read()).toBeNull(); -}); -test("models config API key overrides sticky OAuth", () => { - const f = fixture(); - f.row(1); - f.sticky(1); - f.db.close(); - writeFileSync( - join(f.agentDir, "models.yml"), - "providers:\n openai-codex:\n apiKey: fixture-key\n", - ); - expect(f.read()).toBeNull(); -}); -test("provider mismatch and malformed sticky fail closed", () => { - const f = fixture(); - f.row(1, "anthropic"); - f.sticky(1); - f.db.close(); - expect(f.read()).toBeNull(); -}); - -test("Anthropic OAuth maps to Claude and changes are read on each request", () => { - const f = fixture(); - f.row(1, "anthropic"); - f.db.close(); - expect( - resolveOmpUsageReference("session-1", "anthropic", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: f.agentDir, - }), - ).toEqual({ - source: "claude", - input: { accessToken: "fixture-token-1" }, - }); - writeFileSync( - join(f.agentDir, "config.yml"), - "auth:\n broker:\n url: https://broker.invalid\n", - ); - expect( - resolveOmpUsageReference("session-1", "anthropic", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: f.agentDir, - }), - ).toBeNull(); -}); - -test("named profile ignores agent override and uses existing XDG profile data", () => { - const f = fixture(); - f.db.close(); - const root = mkdtempSync(join(tmpdir(), "omp-profile-")); - dirs.push(root); - const data = join(root, "xdg", "omp", "profiles", "work"); - mkdirSync(data, { recursive: true }); - const profileDb = new DatabaseSync(join(data, "agent.db")); - profileDb.exec(OMP_AUTH_SCHEMA); - profileDb - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run( - 1, - "openai-codex", - "oauth", - JSON.stringify({ access: "profile-token", expires: future }), - null, - ); - profileDb.close(); - expect( - resolveOmpUsageReference( - "session-1", - "openai-codex", - { - OMP_PROFILE: "work", - PI_PROFILE: "other", - PI_CODING_AGENT_DIR: f.agentDir, - PI_CONFIG_DIR: join(root, "config"), - XDG_DATA_HOME: join(root, "xdg"), - }, - "linux", - ), - ).toEqual({ source: "codex", input: { accessToken: "profile-token" } }); -}); - -test("missing database returns null without creating a file", () => { - const home = mkdtempSync(join(tmpdir(), "omp-missing-db-")); - dirs.push(home); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - expect( - resolveOmpUsageReference("session-1", "openai-codex", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: agentDir, - XDG_DATA_HOME: "", - }), - ).toBeNull(); - expect(existsSync(join(agentDir, "agent.db"))).toBe(false); -}); - -test("missing cache or credentials table returns null", () => { - const first = fixture(); - first.db.exec("DROP TABLE cache"); - first.db.close(); - expect(first.read()).toBeNull(); - const second = fixture(); - second.db.exec("DROP TABLE auth_credentials"); - second.db.close(); - expect(second.read()).toBeNull(); -}); diff --git a/packages/server/src/server/agent/providers/omp/usage-reference.ts b/packages/server/src/server/agent/providers/omp/usage-reference.ts deleted file mode 100644 index 384e59697e..0000000000 --- a/packages/server/src/server/agent/providers/omp/usage-reference.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { join } from "node:path"; - -import { z } from "zod"; -import { parse as parseYaml } from "yaml"; -import type { UsageReference } from "../../agent-sdk-types.js"; -import { resolveOmpDiagnosticPaths } from "./provider-config.js"; - -const stickySchema = z.object({ type: z.string(), credentialId: z.number().int().positive() }); -const rowSchema = z.object({ - id: z.number().int().positive(), - provider: z.string(), - credential_type: z.string(), - data: z.string(), - disabled_cause: z.string().nullable(), -}); -const oauthSchema = z.object({ - access: z.string().min(1), - expires: z.number(), - accountId: z.string().optional(), - orgId: z.string().optional(), -}); -const configSchema = z - .object({ - "auth.broker.url": z.string().optional(), - auth: z - .object({ broker: z.object({ url: z.string().optional() }).passthrough().optional() }) - .passthrough() - .optional(), - }) - .passthrough(); -const modelsSchema = z - .object({ - providers: z - .record(z.string(), z.object({ apiKey: z.unknown().optional() }).passthrough()) - .optional(), - }) - .passthrough(); - -interface UsageDb { - prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[] }; - close(): void; -} -const localRequire = createRequire(import.meta.url); - -function readYaml(file: string): unknown { - try { - return parseYaml(readFileSync(file, "utf8")); - } catch { - return undefined; - } -} - -function hasKeyOverride(provider: string, env: NodeJS.ProcessEnv, agentDir: string): boolean { - // Upstream: packages/ai/src/auth-broker/discover.ts:87-110,197-225,321-329. - const configFile = ["config.yml", "config.yaml"] - .map((name) => join(agentDir, name)) - .find((file) => existsSync(file)); - const config = configSchema.safeParse(configFile ? readYaml(configFile) : undefined); - if ( - env.OMP_AUTH_BROKER_URL || - (config.success && (config.data.auth?.broker?.url ?? config.data["auth.broker.url"])) - ) - return true; - // Upstream: packages/ai/src/auth/cascade.ts:22-84. - const models = modelsSchema.safeParse(readYaml(join(agentDir, "models.yml"))); - return models.success && models.data.providers?.[provider]?.apiKey !== undefined; -} - -function readCredential( - db: UsageDb, - provider: string, - sessionId: string, -): z.infer | null { - // Upstream: packages/ai/src/auth/affinity.ts:83-88,115-132; sqlite-credential-store.ts:447-448,711-722. - const cache = db - .prepare( - "SELECT value FROM cache WHERE key = ? AND expires_at > CAST(strftime('%s','now') AS INTEGER)", - ) - .get(`session:sticky:${provider}:${sessionId}`) as { value?: unknown } | undefined; - if (cache) { - const sticky = stickySchema.safeParse(JSON.parse(String(cache.value))); - if (!sticky.success || sticky.data.type !== "oauth") return null; - return parseOAuthCredential( - db - .prepare( - "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE id = ?", - ) - .get(sticky.data.credentialId), - provider, - ); - } - // Upstream: packages/ai/src/auth/cascade.ts:276-345 selects OAuth before env or stored API keys. - const rows = db - .prepare( - "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL", - ) - .all(provider); - const validRows = rows - .map((row) => parseOAuthCredential(row, provider)) - .filter((row): row is z.infer => row !== null); - return validRows.length === 1 ? validRows[0] : null; -} - -function parseOAuthCredential( - value: unknown, - provider: string, -): z.infer | null { - const row = rowSchema.safeParse(value); - if ( - !row.success || - row.data.provider !== provider || - row.data.credential_type !== "oauth" || - row.data.disabled_cause !== null - ) - return null; - try { - // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:117-155 stores OAuth data as JSON without type. - const oauth = oauthSchema.safeParse(JSON.parse(row.data.data)); - return oauth.success && oauth.data.expires > Date.now() ? oauth.data : null; - } catch { - return null; - } -} - -/** Read OMP's current credential attribution without changing its store. */ -export function resolveOmpUsageReference( - sessionId: string, - provider: string, - env: NodeJS.ProcessEnv, - platform: NodeJS.Platform = process.platform, -): UsageReference | null { - let source: string; - if (provider === "openai-codex") source = "codex"; - else if (provider === "anthropic") source = "claude"; - else return null; - if (!sessionId) return null; - // Upstream: packages/utils/src/dirs.ts:82-94,330-343,583-585 (profile, override, agent dir). - const paths = resolveOmpDiagnosticPaths(env, undefined, platform); - if (hasKeyOverride(provider, env, paths.agentDir)) return null; - let db: UsageDb; - try { - const sqlite = localRequire("node:sqlite") as { - DatabaseSync: new (path: string, options: { readOnly: boolean }) => UsageDb; - }; - db = new sqlite.DatabaseSync(paths.agentDb, { readOnly: true }); - } catch { - return null; - } - try { - const oauth = readCredential(db, provider, sessionId); - if (!oauth) return null; - if (source === "claude") { - return { source, input: { accessToken: oauth.access } }; - } - return { - source, - input: { - accessToken: oauth.access, - ...(oauth.accountId ? { accountId: oauth.accountId } : {}), - }, - }; - } catch { - return null; - } finally { - db.close(); - } -} 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 0634ec25cb..d6b6c5513a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -403,100 +403,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }, ); - test("usage reference follows the active OpenCode model and OAuth account", async () => { - const cwd = tmpCwd(); - const runtime = new TestOpenCodeHarness(); - const openCode = new TestOpenCodeClient(); - runtime.enqueueClient(openCode); - const client = new OpenCodeAgentClient( - logger, - { - env: { - OPENCODE_AUTH_CONTENT: JSON.stringify({ - openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" }, - "opencode-go": { type: "api", key: "go-key" }, - }), - }, - }, - { serverManager: runtime, createClient: runtime.createClient }, - ); - const session = await client.createSession(buildConfig(cwd)); - await session.setModel?.("openai/gpt-5"); - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "oauth-token", accountId: "acct-1" }, - }); - await session.setModel?.("opencode-go/qwen"); - expect(await session.getUsageReference?.()).toEqual({ - source: "opencode-go", - input: { apiKey: "go-key" }, - }); - await session.setModel?.("other/model"); - expect(await session.getUsageReference?.()).toBeNull(); - const events: AgentStreamEvent[] = []; - session.subscribe((event) => events.push(event)); - const sessionId = (await session.getRuntimeInfo()).sessionId; - openCode.emitEvent({ - type: "message.updated", - properties: { - info: { - id: "msg_changed_model", - sessionID: sessionId, - role: "user", - model: { providerID: "openai", modelID: "gpt-5" }, - }, - }, - } as OpenCodeEvent); - await vi.waitFor(async () => - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "oauth-token", accountId: "acct-1" }, - }), - ); - expect(events).toContainEqual({ - type: "model_changed", - provider: "opencode", - runtimeInfo: { - provider: "opencode", - sessionId, - model: "openai/gpt-5", - modeId: null, - thinkingOptionId: null, - }, - }); - await session.close(); - rmSync(cwd, { recursive: true, force: true }); - }); - - test("usage reference ignores another auth entry with a different type", async () => { - const cwd = tmpCwd(); - const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); - const client = new OpenCodeAgentClient( - logger, - { - env: { - OPENCODE_AUTH_CONTENT: JSON.stringify({ - openai: { type: "api", key: "other-key" }, - "opencode-go": { type: "api", key: "go-key" }, - }), - }, - }, - { serverManager: runtime, createClient: runtime.createClient }, - ); - const session = await client.createSession(buildConfig(cwd)); - try { - await session.setModel?.("opencode-go/qwen"); - expect(await session.getUsageReference?.()).toEqual({ - source: "opencode-go", - input: { apiKey: "go-key" }, - }); - } finally { - await session.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }); - test("creates a session with valid id and provider", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); @@ -1712,6 +1618,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1822,6 +1729,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1906,6 +1814,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1947,6 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), undefined, undefined, @@ -1975,6 +1885,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); await session.close(); @@ -1999,6 +1910,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), events, ); @@ -2071,6 +1983,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2161,6 +2074,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2213,6 +2127,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2326,6 +2241,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2441,6 +2357,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const events: AgentStreamEvent[] = []; @@ -3240,6 +3157,7 @@ describe("OpenCode adapter startTurn error handling", () => { sdkClient, "ses_sync_abort_failure", createTestLogger(), + {}, ); try { @@ -3838,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 = [ @@ -3893,6 +3814,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_timeout", createTestLogger(), + {}, new Map(), { ready: () => new Promise(() => undefined), @@ -3946,6 +3868,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_slow_stream", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -3978,6 +3901,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_retry", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -5656,6 +5580,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -5814,6 +5739,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -7070,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 b2cab99c6e..5b75733f33 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -12,9 +13,7 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -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"; @@ -58,7 +57,6 @@ import { type SteerResult, type ToolCallDetail, type ToolCallTimelineItem, - type UsageReference, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { @@ -1494,6 +1492,7 @@ export class OpenCodeAgentClient implements AgentClient { client, session.id, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1503,7 +1502,6 @@ export class OpenCodeAgentClient implements AgentClient { false, unbindBridge, connectServer, - { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -1549,6 +1547,7 @@ export class OpenCodeAgentClient implements AgentClient { client, handle.sessionId, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1558,7 +1557,6 @@ export class OpenCodeAgentClient implements AgentClient { registeredAcquisition !== null, unbindBridge, connectServer, - { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -1598,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, }; @@ -1935,7 +1934,9 @@ export class OpenCodeAgentClient implements AgentClient { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); } - const providerOptions = OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("opencode", OpenCodeProviderOptionsSchema, config.providerOptions) ?? + {}; return normalizeOpenCodeConfig({ ...config, provider: "opencode", providerOptions }); } @@ -3359,73 +3360,13 @@ async function listOpenCodeChildSessions( /** One OpenCode server generation a session talks to. */ interface OpenCodeServerConnection { + environment: Record; client: OpencodeClient; events: OpenCodeEventSource; url: string; release: () => Promise; } -const openCodeOAuthAuthSchema = z - .object({ - openai: z - .object({ - type: z.literal("oauth"), - access: z.string().min(1), - accountId: z.string().optional(), - }) - .passthrough() - .optional(), - }) - .passthrough(); -const openCodeGoAuthSchema = z - .object({ - "opencode-go": z - .object({ type: z.literal("api"), key: z.string().min(1) }) - .passthrough() - .optional(), - }) - .passthrough(); - -async function readOpenCodeUsageAuth(env: NodeJS.ProcessEnv): Promise { - try { - const content = - env.OPENCODE_AUTH_CONTENT ?? - (await fs.readFile( - path.join( - env.XDG_DATA_HOME || path.join(env.HOME || os.homedir(), ".local", "share"), - "opencode", - "auth.json", - ), - "utf8", - )); - return JSON.parse(content); - } catch { - return null; - } -} - -function resolveOpenCodeUsageReference(model: string, auth: unknown): UsageReference | null { - if (!auth) return null; - if (model.startsWith("openai/")) { - const parsed = openCodeOAuthAuthSchema.safeParse(auth); - if (!parsed.success) return null; - const credential = parsed.data.openai; - if (!credential) return null; - return { - source: "codex", - input: { - accessToken: credential.access, - ...(credential.accountId ? { accountId: credential.accountId } : {}), - }, - }; - } - const parsed = openCodeGoAuthSchema.safeParse(auth); - if (!parsed.success) return null; - const credential = parsed.data["opencode-go"]; - if (!credential) return null; - return { source: "opencode-go", input: { apiKey: credential.key } }; -} - class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; @@ -3500,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, @@ -3514,10 +3457,15 @@ class OpenCodeAgentSession implements AgentSession { private readonly externallyDriven = false, releaseBridge?: () => void, connectServer?: () => Promise, - private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { 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 }); @@ -3532,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; } @@ -3620,13 +3578,6 @@ class OpenCodeAgentSession implements AgentSession { }; } - async getUsageReference(): Promise { - const model = this.config.model; - if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null; - const auth = await readOpenCodeUsageAuth(this.usageEnv); - return resolveOpenCodeUsageReference(model, auth); - } - async setModel(modelId: string | null): Promise { await this.reconnectIfServerExited(); const normalizedModelId = diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index c8146bf2d1..d3d295cc8e 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -8,6 +8,10 @@ import { expect, test } from "vitest"; import { execCommand } from "../../../utils/spawn.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + getRealProviderConfig, + getRealProviderRuntimeSettings, +} from "../../daemon-e2e/real-provider-test-config.js"; import type { PaseoToolCatalog } from "../tools/types.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; @@ -199,6 +203,72 @@ test("real OpenCode server shares one process while shell.env stays session-scop } }, 240_000); +test("real OpenCode server asks before running a Paseo tool with an ask rule", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-ask-")); + const cwd = path.join(root, "repo"); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + await bridge.start(); + const catalog = createCallerCatalog("ask-rule-agent"); + bridge.setManifestCatalog(catalog); + const realSettings = getRealProviderRuntimeSettings("opencode"); + const runtimeSettings = { + ...realSettings, + env: { + ...realSettings.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: { "paseo_*": "ask" } }), + }, + }; + const manager = new OpenCodeServerManager({ + logger, + runtimeSettings, + resolveHomeDir: () => root, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + const client = new OpenCodeAgentClient(logger, runtimeSettings, { + serverManager: manager, + bridge, + }); + let session: Awaited> | undefined; + + try { + await mkdir(cwd); + session = await client.createSession( + { ...getRealProviderConfig("opencode"), cwd }, + { agentId: "ask-rule-agent", paseoTools: catalog }, + { persistSession: false }, + ); + const asked: string[] = []; + const completedTools: string[] = []; + const activeSession = session; + session.subscribe((event) => { + if (event.type === "permission_requested") { + asked.push(event.request.name); + void activeSession.respondToPermission(event.request.id, { behavior: "allow" }); + } + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed" + ) { + completedTools.push(event.item.name); + } + }); + + await session.run( + "Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.", + ); + + expect(completedTools).toContain("paseo_report_caller_agent_id"); + expect(asked).toContain("paseo_report_caller_agent_id"); + } finally { + await session?.close(); + await manager.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { const tool = { name: "report_caller_agent_id", @@ -355,7 +425,8 @@ test.each([ await resumed?.close(); await original?.close(); await client.shutdown(); - await rm(root, { recursive: true, force: true }); + // Windows can retain a transient executable lock after provider shutdown. + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 240_000, 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/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs index 058ade60f8..82e4166492 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs @@ -33,6 +33,13 @@ export default async function paseoPlugin(input, options) { description: definition.description, args: jsonSchemaObjectToZodShape(definition.inputSchema), execute: async (args, context) => { + // OpenCode leaves permission checks to plugin tools, so ask it the way it asks for MCP tools. + await context.ask({ + permission: `paseo_${definition.name}`, + patterns: ["*"], + always: ["*"], + metadata: {}, + }); let result; try { result = await request( diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 2df81b3241..13cd6aa4d8 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -102,6 +102,12 @@ describe("OpenCodeBridge", () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-")); temporaryDirectories.push(paseoHome); const catalog = createCatalog(); + const executedInputs: unknown[] = []; + const executeTool = catalog.executeTool; + catalog.executeTool = async (name, input, context) => { + executedInputs.push(input); + return await executeTool(name, input, context); + }; const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); await bridge.start(); bridge.setManifestCatalog(catalog); @@ -173,12 +179,30 @@ describe("OpenCodeBridge", () => { token: plugin.token, }, ); + const asked: unknown[] = []; await expect( hooks.tool.paseo_echo_context.execute( { value: "through bundled plugin" }, - { sessionID: "ses_one" }, + { sessionID: "ses_one", ask: async (request: unknown) => void asked.push(request) }, ), ).resolves.toMatchObject({ output: "through bundled plugin" }); + expect(asked).toEqual([ + { permission: "paseo_echo_context", patterns: ["*"], always: ["*"], metadata: {} }, + ]); + + executedInputs.length = 0; + await expect( + hooks.tool.paseo_echo_context.execute( + { value: "rejected" }, + { + sessionID: "ses_one", + ask: async () => { + throw new Error("The user rejected permission to use this specific tool call."); + }, + }, + ), + ).rejects.toThrow("rejected permission"); + expect(executedInputs).toEqual([]); release(); const pluginError = vi.spyOn(console, "error").mockImplementation(() => undefined); 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/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 39a7deb8c9..8214ebb1d0 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -312,6 +312,10 @@ const OpencodeKnownToolDetailSchema = z.union([ z.unknown(), (input) => toEditToolDetail(input, null), ), + // OpenCode v2 reports apply_patch as `patch`. + toolDetailBranchByToolName("patch", OpencodeApplyPatchTextInputSchema, z.unknown(), (input) => + toEditToolDetail(input, null), + ), toolDetailBranchByToolName( "apply_diff", OpencodeEditInputSchema, diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 54f3faa514..1c9ba212e1 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -387,6 +387,34 @@ describe("opencode tool-call mapper", () => { }); }); + it("maps OpenCode v2 patch payloads from GPT models into edit detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "patch", + callId: "opencode-v2-patch-1", + status: "completed", + input: { + patchText: + "*** Begin Patch\n*** Update File: patch.txt\n@@\n-north south\n+NORTH south\n*** End Patch", + }, + output: "Success. Updated the following files:\nM patch.txt", + }), + ); + + expect(item.detail).toEqual({ + type: "edit", + filePath: "patch.txt", + unifiedDiff: [ + "diff --git a/patch.txt b/patch.txt", + "--- a/patch.txt", + "+++ b/patch.txt", + "@@", + "-north south", + "+NORTH south", + ].join("\n"), + }); + }); + it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( mapOpencodeToolCall({ diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index f7a19006d9..cb71ff7212 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -35,6 +35,7 @@ import { features } from "./configuration.js"; import { commands } from "./commands.js"; import { messages } from "./history.js"; import { SessionPermissions } from "./permissions.js"; +import { SessionUsage } from "./usage.js"; export class OpenCodeV2Session implements AgentSession { readonly provider = "opencode"; @@ -45,6 +46,7 @@ export class OpenCodeV2Session implements AgentSession { private readonly permissions: SessionPermissions; private readonly turns: SessionTurns; private readonly children: SessionChildren; + private readonly usage: SessionUsage; private stream: Promise | null = null; private streamAbort = new AbortController(); private exited = false; @@ -101,6 +103,17 @@ export class OpenCodeV2Session implements AgentSession { await this.permissions.respondToPermission(request.id, { behavior: "deny" }); }, }); + this.usage = new SessionUsage({ + client: () => this.client, + cwd: config.cwd, + info: () => this.info, + emit: (event) => this.emit(event), + reportError: (error) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode context usage update failed", + ), + }); } get id() { return this.info.id; @@ -465,6 +478,7 @@ export class OpenCodeV2Session implements AgentSession { return; } this.permissions.observe(event); + this.usage.observe(event); this.turns.observe(event); this.scheduleReconcile(); } diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts new file mode 100644 index 0000000000..4fa64079c2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts @@ -0,0 +1,100 @@ +import type { ModelInfo, TokenUsageInfo } from "@opencode/client"; +import { describe, expect, test } from "vitest"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentUsage } from "../../../agent-sdk-types.js"; + +function model(context: number): ModelInfo { + return { + id: "model", + modelID: "model", + providerID: "test", + name: "Model", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context, output: 10_000 }, + }; +} + +function stepEnded(harness: V2Harness, id: string, tokens: TokenUsageInfo) { + harness.push({ + id, + created: 2, + type: "session.step.ended", + durable: { aggregateID: "session", seq: 1, version: 1 }, + data: { + sessionID: "session", + assistantMessageID: "answer", + finish: "tool-calls", + cost: 0.1, + tokens, + }, + }); +} + +describe("OpenCode v2 context usage", () => { + test("reports each step's context against the model limit, in step order", async () => { + const harness = new V2Harness(); + harness.info.model = { providerID: "test", id: "model" }; + harness.info.tokens = { + input: 900, + output: 90, + reasoning: 0, + cache: { read: 40, write: 0 }, + }; + harness.info.cost = 0.5; + // Each lookup answers faster than the one before it, so a lookup per step would + // deliver step 1's usage after step 2's. + const lookupDelays = [50, 0]; + harness.api.model.list = async (input) => { + await new Promise((resolve) => setTimeout(resolve, lookupDelays.shift() ?? 0)); + return { + location: { directory: input.location.directory }, + data: [model(200_000)], + }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + }); + const usage: AgentUsage[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") usage.push(event.usage); + }); + try { + stepEnded(harness, "step-1", { + input: 100, + output: 10, + reasoning: 5, + cache: { read: 1_000, write: 20 }, + }); + stepEnded(harness, "step-2", { + input: 300, + output: 10, + reasoning: 5, + cache: { read: 1_000, write: 20 }, + }); + await expect.poll(() => usage.length).toBe(2); + expect(usage.map((entry) => entry.contextWindowUsedTokens)).toEqual([1_135, 1_335]); + expect(usage[1]).toEqual({ + inputTokens: 900, + outputTokens: 90, + cachedInputTokens: 40, + totalCostUsd: 0.5, + contextWindowUsedTokens: 1_335, + contextWindowMaxTokens: 200_000, + }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.ts new file mode 100644 index 0000000000..17a4885cbc --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.ts @@ -0,0 +1,61 @@ +import type { ModelRef, OpenCodeEvent, SessionInfo, TokenUsageInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Api } from "./api.js"; +import { usageFromV2 } from "./mapping.js"; + +interface UsageOptions { + client(): V2Api; + cwd: string; + info(): SessionInfo; + emit(event: AgentStreamEvent): void; + reportError(error: unknown): void; +} + +// A step is one model call, so its tokens are the context that call used. +function contextWindowUsedTokens(tokens: TokenUsageInfo) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write; +} + +export class SessionUsage { + private readonly limits = new Map>(); + private reports: Promise = Promise.resolve(); + constructor(private readonly options: UsageOptions) {} + observe(event: OpenCodeEvent) { + if (event.type !== "session.step.ended") return; + const { tokens } = event.data; + // Reports stay in step order while a model's context limit is still being looked up. + this.reports = this.reports + .then(() => this.report(tokens)) + .catch((error: unknown) => this.options.reportError(error)); + } + private async report(tokens: TokenUsageInfo) { + const model = this.options.info().model; + const contextWindowMaxTokens = model ? await this.contextLimit(model) : undefined; + this.options.emit({ + type: "usage_updated", + provider: "opencode", + usage: { + ...usageFromV2(this.options.info()), + contextWindowUsedTokens: contextWindowUsedTokens(tokens), + ...(contextWindowMaxTokens === undefined ? {} : { contextWindowMaxTokens }), + }, + }); + } + private contextLimit(model: ModelRef) { + const key = `${model.providerID}/${model.id}`; + const cached = this.limits.get(key); + if (cached) return cached; + const limit = this.options + .client() + .model.list({ location: { directory: this.options.cwd } }) + .then( + (catalog) => + catalog.data.find( + (entry) => entry.providerID === model.providerID && entry.id === model.id, + )?.limit.context, + ); + this.limits.set(key, limit); + limit.catch(() => this.limits.delete(key)); + return limit; + } +} 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 7308185db0..8035d59830 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -18,12 +18,12 @@ import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { - PiProviderParamsSchema, + PiProviderOptionsSchema, PiRpcAgentClient, PiRpcAgentSession, transformPiModels, } from "./agent.js"; -import { FakePi, type FakePiSession } from "./test-utils/fake-pi.js"; +import { FakePi } from "./test-utils/fake-pi.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; @@ -56,8 +56,8 @@ interface PiThinkingCatalogCase { } test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => { - expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000); - expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); + expect(PiProviderOptionsSchema.parse({}).rpcTimeoutMs).toBe(60_000); + expect(PiProviderOptionsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); }); function createClient( @@ -160,21 +160,34 @@ function parseEntryCapture(notification: string): unknown { async function loadPaseoExtensionListeners( extensionPath: string, + registerMcpServer: (name: string, config: unknown) => void = () => undefined, ): Promise> { const listeners = new Map(); const extension = (await import(pathToFileURL(extensionPath).href)) as { default: (piApi: { on: (event: string, listener: PaseoExtensionListener) => void; + events: { on: () => void }; registerCommand: () => void; + registerMcpServer: (name: string, config: unknown) => void; }) => void; }; extension.default({ on: (event, listener) => listeners.set(event, listener), + events: { on: () => undefined }, registerCommand: () => undefined, + registerMcpServer, }); return listeners; } +async function readRegisteredMcpServers(extensionPath: string): Promise> { + const servers: Record = {}; + await loadPaseoExtensionListeners(extensionPath, (name, config) => { + servers[name] = config; + }); + return servers; +} + async function applyPaseoExtensionSystemPrompt( extensionPath: string, systemPrompt: string, @@ -202,71 +215,6 @@ async function createSession( return { pi, session, events }; } -test("Pi usage reference follows the current OAuth model and agent directory", async () => { - const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-usage-")); - try { - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ - "openai-codex": { - type: "oauth", - access: "codex-token", - accountId: "account-1", - refresh: "refresh", - expires: Date.now() + 60_000, - }, - anthropic: { - type: "oauth", - access: "claude-token", - refresh: "refresh", - expires: Date.now() + 60_000, - }, - }), - ); - const pi = new FakePi(); - let runtime!: FakePiSession; - pi.queueSessionSetup((sessionRuntime) => { - runtime = sessionRuntime; - runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; - }); - const session = await createClient(pi).createSession(createConfig(), { - env: { PI_CODING_AGENT_DIR: agentDir }, - }); - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "codex-token", accountId: "account-1" }, - }); - runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { accessToken: "claude-token" }, - }); - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ - "openai-codex": { type: "oauth", access: "codex-token" }, - anthropic: { type: "api_key", key: "api-key" }, - }), - ); - runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "codex-token" }, - }); - runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }), - ); - expect(await session.getUsageReference?.()).toBeNull(); - runtime.state.model = { provider: "other", id: "other", name: "Other" }; - expect(await session.getUsageReference?.()).toBeNull(); - await session.close(); - } finally { - rmSync(agentDir, { recursive: true, force: true }); - } -}); - test("forwards launch-context env to the Pi process launch", async () => { const pi = new FakePi(); const client = createClient(pi); @@ -1547,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", @@ -1562,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({ @@ -1574,8 +1528,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1585,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) => { @@ -1761,8 +1794,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -2517,10 +2548,11 @@ describe("PiRpcAgentClient", () => { const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: new FakePi(), - providerParams: { sessionDir: sessionsDir }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toEqual([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toEqual([ { providerHandleId: sessionFile, cwd, @@ -2568,7 +2600,9 @@ describe("PiRpcAgentClient", () => { }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toMatchObject([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toMatchObject([ { providerHandleId: sessionFile, cwd, @@ -2619,12 +2653,16 @@ 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({ logger: pino({ level: "silent" }), runtime: pi, - providerParams: { sessionDir: sessionsDir }, }); const imported = await client.importSession( @@ -2638,8 +2676,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/anthropic/claude-sonnet-4.5", "--thinking", "high", "--session", @@ -3192,6 +3228,102 @@ describe("PiRpcAgentClient", () => { ).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`); }); + test("registers MCP servers through Pi's built-in MCP when pi-mcp-adapter is not loaded", async () => { + const pi = new FakePi(); + pi.queueCommands([ + { + name: "mcp", + source: "extension", + sourceInfo: { path: "builtin:mcp", source: "builtin" }, + }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { type: "stdio", command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }, + }), + ); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).not.toContain("--mcp-config"); + const extensionPath = actualLaunch.extensionPaths[0]!; + expect(await readRegisteredMcpServers(extensionPath)).toEqual({ + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }); + + await session.close(); + expect(existsSync(extensionPath)).toBe(false); + }); + + test("registers the resumed session's MCP servers through Pi's built-in MCP", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const original = await client.createSession(createConfig()); + const persistence = original.describePersistence()!; + await original.close(); + + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + const session = await client.resumeSession(persistence, { + mcpServers: { + paseo: { type: "http", url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }, + }); + onTestFinished(() => session.close()); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches.at(-1)!; + expect(actualLaunch.session).toBe(persistence.nativeHandle); + expect(actualLaunch.argv).not.toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({ + paseo: { url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }); + }); + + test("uses pi-mcp-adapter when it replaces Pi's built-in MCP", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); + onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); + const pi = new FakePi(); + pi.queueCommands([ + { name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }, + { name: "mcp:1", source: "extension", sourceInfo: { source: "npm:pi-mcp-adapter" } }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" } }, + }), + { env: { PI_CODING_AGENT_DIR: agentDir } }, + ); + onTestFinished(() => session.close()); + + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({}); + }); + + test.each([ + { name: "legacy", config: { type: "sse" as const, url: "https://example.com/sse" } }, + { name: "foo.bar", config: { type: "http" as const, url: "https://example.com/mcp" } }, + ])("rejects $name, which Pi's built-in MCP cannot register", async ({ name, config }) => { + const pi = new FakePi(); + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + + await expect( + createClient(pi).createSession(createConfig({ mcpServers: { [name]: config } })), + ).rejects.toThrow(name); + expect(pi.recordedLaunches).toHaveLength(1); + }); + test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => { const pi = new FakePi(); pi.queueCommands([]); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 5d7966367e..5c9c7bb949 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; @@ -66,7 +65,11 @@ import { } from "./history-mapper.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { PiCliRuntime } from "./cli-runtime.js"; -import { createPiExtensionHost, type PiExtensionEventOutput } from "./extensions/index.js"; +import { + createPiExtensionHost, + piExtensionRuntimeBridge, + type PiExtensionEventOutput, +} from "./extensions/index.js"; import { revertPiConversation } from "./rewind.js"; import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js"; @@ -102,7 +105,7 @@ const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000; const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000; const QUESTION_RESPONSE_HEADER = "Response"; -export const PiProviderParamsSchema = z +export const PiProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().default(DEFAULT_PI_RPC_TIMEOUT_MS), @@ -110,8 +113,6 @@ export const PiProviderParamsSchema = z }) .strict(); -type PiProviderParams = z.infer; - const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ { name: "compact", @@ -183,7 +184,6 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{ export interface PiRpcAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: PiRuntime; usagePollScheduler?: PiUsagePollScheduler; } @@ -243,7 +243,6 @@ interface PiRpcAgentSessionOptions { extensionTimeoutMs?: number; logger: Logger; usagePollScheduler?: PiUsagePollScheduler; - usageEnv?: Record; } interface PiResumeConfig { @@ -260,13 +259,17 @@ interface PiMcpServerConfig { env?: Record; url?: string; headers?: Record; - auth?: false; - oauth?: false; } -interface PiMcpConfigFile { - path: string; - cleanup: () => void; +// Pi's built-in MCP extension takes servers from Paseo's extension; pi-mcp-adapter replaces it +// and takes them from a --mcp-config file. +type PiMcpInjection = + | { kind: "builtin"; servers: Record } + | { kind: "adapter"; configFile: PiTempFile }; + +interface PiPaseoExtensionOptions { + systemPrompt?: string; + mcpServers?: Record; } interface PiTempFile { @@ -485,16 +488,15 @@ function buildResumeStartInput(input: { resumeConfig: PiResumeConfig; sessionFile: string; launchContext: AgentLaunchContext | undefined; - mcpConfig: PiMcpConfigFile | null; + mcpConfigFile: PiTempFile | null; paseoExtension: PiTempFile | null; }): PiStartSessionInput { return { cwd: input.resumeConfig.cwd, env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, - mcpConfigPath: input.mcpConfig?.path, + mcpConfigPath: input.mcpConfigFile?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, }; } @@ -511,11 +513,36 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { return { url: config.url, ...(config.headers ? { headers: config.headers } : {}), - auth: false, - oauth: false, }; } +function toPiMcpAdapterConfig( + config: McpServerConfig, +): PiMcpServerConfig & { auth?: false; oauth?: false } { + const piConfig = toPiMcpConfig(config); + return config.type === "stdio" ? piConfig : { ...piConfig, auth: false, oauth: false }; +} + +function toPiBuiltinMcpServers( + servers: Record, +): Record { + const piServers: Record = {}; + for (const [name, config] of Object.entries(servers)) { + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + throw new Error( + `Pi's built-in MCP cannot register server "${name}": names may only contain letters, digits, "_", and "-".`, + ); + } + if (config.type === "sse") { + throw new Error( + `Pi's built-in MCP cannot register SSE server "${name}": use streamable HTTP or load pi-mcp-adapter.`, + ); + } + piServers[name] = toPiMcpConfig(config); + } + return piServers; +} + function resolvePiAgentDir(env: Record | undefined): string { const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); if (!configured) { @@ -530,27 +557,6 @@ function resolvePiAgentDir(env: Record | undefined): string { return resolvePath(configured); } -const piCodexUsageAuthSchema = z - .object({ - "openai-codex": z - .object({ - type: z.literal("oauth"), - access: z.string().min(1), - accountId: z.string().optional(), - }) - .passthrough() - .optional(), - }) - .passthrough(); -const piClaudeUsageAuthSchema = z - .object({ - anthropic: z - .object({ type: z.literal("oauth"), access: z.string().min(1) }) - .passthrough() - .optional(), - }) - .passthrough(); - function readPiGlobalMcpConfig(env: Record | undefined): Record { const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json"); if (!existsSync(globalConfigPath)) { @@ -577,7 +583,7 @@ function createPiMcpConfigFile( options?: { piGlobalConfigEnv?: Record; }, -): PiMcpConfigFile { +): PiTempFile { const globalConfig = options?.piGlobalConfigEnv ? readPiGlobalMcpConfig(options.piGlobalConfigEnv) : {}; @@ -589,7 +595,7 @@ function createPiMcpConfigFile( } const mcpServers: Record = { ...configuredServers }; for (const [name, serverConfig] of Object.entries(servers)) { - mcpServers[name] = toPiMcpConfig(serverConfig); + mcpServers[name] = toPiMcpAdapterConfig(serverConfig); } const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); @@ -606,7 +612,10 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { +function createPiPaseoExtensionFile({ + systemPrompt, + mcpServers = {}, +}: PiPaseoExtensionOptions): PiTempFile { const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); const filePath = join(dir, "paseo-integration.mjs"); writeFileSync( @@ -666,7 +675,11 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { } export default function paseoIntegration(pi) { + for (const [name, config] of Object.entries(${JSON.stringify(mcpServers)})) { + pi.registerMcpServer(name, config); + } const submittedUserMessages = []; + ${piExtensionRuntimeBridge} function emitSubmittedUserEntries(ctx) { const entries = ctx.sessionManager.getEntries(); @@ -746,7 +759,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { }); } `.trimStart(), - "utf8", + // MCP server headers and env can carry credentials. + { encoding: "utf8", mode: 0o600 }, ); return { path: filePath, @@ -776,6 +790,14 @@ function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { return JSON.stringify(command.sourceInfo).includes("pi-mcp-adapter"); } +function isPiBuiltinMcpCommand(command: PiRpcSlashCommand): boolean { + return ( + command.source === "extension" && + command.name === "mcp" && + command.sourceInfo?.path === "builtin:mcp" + ); +} + function withPiCapabilities(supportsMcpServers: boolean): AgentCapabilityFlags { return { ...PI_CAPABILITIES, @@ -1145,35 +1167,6 @@ export class PiRpcAgentSession implements AgentSession { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; - async getUsageReference() { - await this.refreshState(); - const provider = this.state.model?.provider; - let source: string | null = null; - if (provider === "openai-codex") source = "codex"; - if (provider === "anthropic") source = "claude"; - if (!source || !provider) return null; - try { - const auth: unknown = JSON.parse( - await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"), - ); - if (provider === "openai-codex") { - const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"]; - if (!credential) return null; - return { - source, - input: { - accessToken: credential.access, - ...(credential.accountId ? { accountId: credential.accountId } : {}), - }, - }; - } - const credential = piClaudeUsageAuthSchema.parse(auth).anthropic; - return credential ? { source, input: { accessToken: credential.access } } : null; - } catch { - return null; - } - } - private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); @@ -1201,7 +1194,6 @@ export class PiRpcAgentSession implements AgentSession { private readonly currentModeId: string | null; private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; - private readonly usageEnv?: Record; private closed = false; private readonly closeController = new AbortController(); private readonly pendingExtensionHydrations = new Set>(); @@ -1209,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; @@ -1221,7 +1226,9 @@ export class PiRpcAgentSession implements AgentSession { this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; this.extensionHost = createPiExtensionHost(this.logger); - this.usageEnv = options.usageEnv; + this.extensionHost.follow((event) => { + if (!this.closeController.signal.aborted) this.emit(event); + }); this.usagePoller = new PiUsagePoller({ scheduler: options.usagePollScheduler, readStats: () => this.runtimeSession.getSessionStats(), @@ -1577,6 +1584,7 @@ export class PiRpcAgentSession implements AgentSession { } this.closed = true; this.closeController.abort(); + this.extensionHost.close(); this.usagePoller.close(); try { await this.runtimeSession.close(); @@ -1990,6 +1998,11 @@ export class PiRpcAgentSession implements AgentSession { ): void { const message = optionalString(event.message); if (event.method === "notify" && message) { + const extensionMapping = this.extensionHost.mapRuntimeNotification(message); + if (extensionMapping) { + this.emitExtensionOutput(extensionMapping, this.currentTurnIdForEvent()); + return; + } if ( this.handleSubmittedUserEntryMarker(message) || this.handleEntryCaptureMarker(message) || @@ -2461,8 +2474,7 @@ export class PiRpcAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: PiProviderParams; - private readonly runtime: PiRuntime; + private readonly runtime?: PiRuntime; private readonly usagePollScheduler?: PiUsagePollScheduler; constructor(options: PiRpcAgentClientOptions) { @@ -2470,10 +2482,7 @@ export class PiRpcAgentClient implements AgentClient { this.capabilities = capabilitiesForClient(); this.logger = options.logger; this.runtimeSettings = options.runtimeSettings; - this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {}); - this.runtime = - options.runtime ?? - createRuntime(options.logger, options.runtimeSettings, this.providerParams.rpcTimeoutMs); + this.runtime = options.runtime; this.usagePollScheduler = options.usagePollScheduler; } @@ -2481,27 +2490,31 @@ export class PiRpcAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(config.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), - ); + const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv, runtime); + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: config.cwd, model: config.model, thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined, noSession: config.internal === true, env: launchContext?.env, - mcpConfigPath: mcpConfig?.path, + mcpConfigPath: mcpConfigFile?.path, extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, }); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2510,16 +2523,15 @@ export class PiRpcAgentClient implements AgentClient { runtimeSession, config, initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, - usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2538,61 +2550,102 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); + const providerOptions = PiProviderOptionsSchema.parse( + resumeConfig.config.providerOptions ?? {}, + ); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig( + const mcp = await this.prepareMcpInjection( resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, + runtime, ); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts( + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts( resumeConfig.config.systemPrompt, resumeConfig.config.daemonAppendSystemPrompt, ), - ); + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession( + runtimeSession = await runtime.startSession( buildResumeStartInput({ resumeConfig, sessionFile, launchContext, - mcpConfig, + mcpConfigFile, paseoExtension, }), ); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } try { + const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model); return new PiRpcAgentSession({ runtimeSession, - config: resumeConfig.config, - initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + config: { + ...resumeConfig.config, + model: modelToId(initialState.model) ?? resumeConfig.config.model, + }, + initialState, + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, - usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } } + // 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, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(options.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); let runtimeSession: PiRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2604,7 +2657,7 @@ export class PiRpcAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, signal: context?.signal, }); @@ -2646,7 +2699,7 @@ export class PiRpcAgentClient implements AgentClient { ): Promise { return await listPiImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: PiProviderOptionsSchema.parse(options?.providerOptions ?? {}).sessionDir, runtimeSettings: this.runtimeSettings, }); } @@ -2698,33 +2751,53 @@ export class PiRpcAgentClient implements AgentClient { } } - private async prepareMcpConfig( + private resolveRuntime(rpcTimeoutMs: number): PiRuntime { + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, rpcTimeoutMs); + } + + private async prepareMcpInjection( cwd: string, servers: Record | undefined, env: Record | undefined, - ): Promise { + runtime: PiRuntime, + ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } - if (!(await this.detectMcpAdapter(cwd, env))) { - return null; + const mcpSupport = await this.detectMcpSupport(cwd, env, runtime); + if (mcpSupport === "builtin") { + return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) }; } - return createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }); + if (mcpSupport === "adapter") { + return { + kind: "adapter", + configFile: createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }), + }; + } + return null; } - private async detectMcpAdapter(cwd: string, env?: Record): Promise { - const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start"); + private async detectMcpSupport( + cwd: string, + env: Record | undefined, + runtime: PiRuntime, + ): Promise { + const runtimeSession = await runtime.startSession({ cwd, env }).catch((error) => { + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed to start"); return null; }); if (!runtimeSession) { - return false; + return null; } try { - return (await runtimeSession.getCommands()).some(isPiMcpAdapterCommand); + const commands = await runtimeSession.getCommands(); + if (commands.some(isPiMcpAdapterCommand)) { + return "adapter"; + } + return commands.some(isPiBuiltinMcpCommand) ? "builtin" : null; } catch (error) { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed"); - return false; + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed"); + return null; } finally { await runtimeSession.close().catch(() => undefined); } 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/extensions/child-session.ts b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts index d2dfa0e99b..2013b831be 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts @@ -3,57 +3,96 @@ import { limitAgentTimelineItemContent } from "../../../agent-timeline-content.j import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; import { PiHistoryMapper } from "../history-mapper.js"; import type { PiAgentMessage } from "../rpc-types.js"; +import { extractTextFromToolResult, type PiToolResult } from "../tool-call-mapper.js"; const MAX_BYTES = 2 * 1024 * 1024; const MAX_ITEMS = 200; -/** Child session files are immutable once the completed result exposes their path. */ -export async function mapPiChildSession( +export function outputFileFromToolResult(result: PiToolResult): string | undefined { + return extractTextFromToolResult(result)?.match(/^Output file:\s*(\S+)$/m)?.[1]; +} + +/** Reads a finished child transcript once, up to `maxBytes`. */ +export function mapPiChildSession( id: string, file: string, maxBytes = MAX_BYTES, ): Promise { + return new PiChildSessionFollower(id, file, maxBytes).readNew(); +} + +function mapChildLine( + mapper: PiHistoryMapper, + id: string, + line: string, +): ProviderSubagentInputEvent[] { + let entry: { message?: PiAgentMessage; timestamp?: string }; try { - const handle = await open(file, "r"); - try { - const fileSize = (await handle.stat()).size; - const size = Math.min(fileSize, MAX_BYTES, maxBytes); - if (!size) return []; - const buffer = Buffer.alloc(size); - const { bytesRead } = await handle.read(buffer, 0, size, 0); - const text = buffer.toString("utf8", 0, bytesRead); - const completeText = bytesRead < fileSize ? text.slice(0, text.lastIndexOf("\n")) : text; - return parseChildTimeline(id, completeText); - } finally { - await handle.close(); - } + entry = JSON.parse(line); } catch { return []; } + if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return []; + return mapper.mapMessages([entry.message]).flatMap((mapped) => + mapped.type === "timeline" + ? [ + { + type: "timeline" as const, + id, + item: limitAgentTimelineItemContent(mapped.item), + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + }, + ] + : [], + ); } -function parseChildTimeline(id: string, text: string): ProviderSubagentInputEvent[] { - const mapper = new PiHistoryMapper("pi"); - const events: ProviderSubagentInputEvent[] = []; - for (const line of text.split("\n")) { - if (!line || events.length >= MAX_ITEMS) break; - let entry: { message?: PiAgentMessage; timestamp?: string }; +/** Incrementally reads one append-only child transcript. A partial JSONL row is held until newline. */ +export class PiChildSessionFollower { + private offset = 0; + private pending = Buffer.alloc(0); + private readonly mapper = new PiHistoryMapper("pi"); + private items = 0; + private readonly maxBytes: number; + + constructor( + private readonly id: string, + private readonly file: string, + maxBytes = MAX_BYTES, + ) { + this.maxBytes = Math.min(maxBytes, MAX_BYTES); + } + + async readNew(): Promise { + if (this.offset >= this.maxBytes || this.items >= MAX_ITEMS) return []; try { - entry = JSON.parse(line); + const handle = await open(this.file, "r"); + try { + const size = Math.min((await handle.stat()).size, this.maxBytes) - this.offset; + if (size <= 0) return []; + const buffer = Buffer.alloc(size); + const { bytesRead } = await handle.read(buffer, 0, size, this.offset); + this.offset += bytesRead; + const data = Buffer.concat([this.pending, buffer.subarray(0, bytesRead)]); + const lastNewline = data.lastIndexOf(10); + if (lastNewline < 0) { + this.pending = data; + return []; + } + this.pending = data.subarray(lastNewline + 1); + const events: ProviderSubagentInputEvent[] = []; + for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) { + if (this.items >= MAX_ITEMS) break; + const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items); + this.items += mapped.length; + events.push(...mapped); + } + return events; + } finally { + await handle.close(); + } } catch { - continue; - } - if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) continue; - for (const mapped of mapper.mapMessages([entry.message])) { - if (mapped.type !== "timeline") continue; - events.push({ - type: "timeline", - id, - item: limitAgentTimelineItemContent(mapped.item), - ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), - }); - if (events.length >= MAX_ITEMS) break; + return []; } } - return events; } diff --git a/packages/server/src/server/agent/providers/pi/extensions/contract.ts b/packages/server/src/server/agent/providers/pi/extensions/contract.ts index 7f9330eaeb..93129a0476 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/contract.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/contract.ts @@ -22,13 +22,21 @@ export interface PiExtensionToolMapping { detail?: ToolCallDetail; timeline?: AgentTimelineItem[]; subagents?: ProviderSubagentInputEvent[]; - /** Completed Pi child sessions to hydrate through the normal Pi history mapper. */ - childSessions?: Array<{ id: string; file: string }>; + childSessions?: PiChildSessionFile[]; } export interface PiExtensionCustomMapping { subagents: ProviderSubagentInputEvent[]; - childSessions?: Array<{ id: string; file: string }>; + childSessions?: PiChildSessionFile[]; +} + +/** + * A Pi child's append-only session file, mapped through the normal Pi history mapper. Report it as + * soon as it is known; a live session follows it until the child's upsert reaches a final status. + */ +export interface PiChildSessionFile { + id: string; + file: string; } export type PiExtensionDialog = Extract; @@ -48,6 +56,10 @@ export interface PiExtensionUiReply { export interface PiExtensionSession { mapToolCall?(call: PiExtensionToolCall): PiExtensionToolMapping | undefined; + /** Claims a `ctx.ui.notify` message sent by this extension's `runtimeBridge`. */ + mapRuntimeNotification?(message: string): PiExtensionCustomMapping | undefined; + /** Called on the live follow cadence to report state Pi only writes to disk. */ + poll?(): PiExtensionCustomMapping | undefined; mapCustomMessage?( message: Extract, ): PiExtensionCustomMapping | undefined; @@ -62,5 +74,10 @@ export interface PiExtensionSession { export interface PiExtension { id: string; + /** + * JavaScript statements run inside Paseo's Pi integration extension, with `pi` in scope. Reports + * state that Pi's RPC stream omits through `ctx.ui.notify`, which reaches `mapRuntimeNotification`. + */ + runtimeBridge?: string; createSession(): PiExtensionSession; } diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index 0e15438384..8e3d6e64e9 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -3,8 +3,98 @@ import { fileURLToPath } from "node:url"; import { createPiExtensionHost } from "../index.js"; import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; import { streamPiHistory } from "../../history-mapper.js"; +import { GOTGENES_CHILD_SESSION_MARKER, gotgenesRuntimeBridge } from "./runtime-bridge.js"; + +const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service"); + +function loadRuntimeBridge() { + const listeners = new Map void>(); + const pi = { + on: (name: string, listener: (event: unknown, ctx?: unknown) => void) => + listeners.set(name, listener), + events: { + on: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), + }, + }; + new Function("pi", gotgenesRuntimeBridge)(pi); + return (name: string, event: unknown, ctx?: unknown) => listeners.get(name)?.(event, ctx); +} + +function publishService(agents: Array<{ id: string; outputFile?: string }>): void { + (globalThis as Record)[SERVICE_KEY] = { listAgents: () => agents }; +} describe("@gotgenes/pi-subagents adapter", () => { + test("reports a child's file after the child republishes the gotgenes service", async () => { + const notifications: string[] = []; + const emit = loadRuntimeBridge(); + const file = "/sessions/parent/tasks/2026-09-30_child-session.jsonl"; + try { + publishService([{ id: "native-1", outputFile: file }]); + emit( + "session_start", + {}, + { ui: { notify: (message: string) => notifications.push(message) } }, + ); + // Pi loads gotgenes again inside the child, and that copy replaces the global service. + publishService([]); + emit("subagents:child:session-created", { sessionId: "child-session" }); + + await expect.poll(() => notifications).toHaveLength(1); + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + expect(host.mapRuntimeNotification(notifications[0]!)?.childSessions).toEqual([ + { id: "call-1", file }, + ]); + } finally { + delete (globalThis as Record)[SERVICE_KEY]; + } + }); + + test("accepts a live child path before or after the spawn result", () => { + for (const early of [true, false]) { + const host = createPiExtensionHost(); + const marker = `${GOTGENES_CHILD_SESSION_MARKER} ${JSON.stringify({ agentId: "native-1", file: "/tmp/child.jsonl" })}`; + if (early) expect(host.mapRuntimeNotification(marker)?.subagents).toEqual([]); + const spawn = host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const path = early + ? spawn?.childSessions + : host.mapRuntimeNotification(marker)?.childSessions; + expect(path).toEqual([{ id: "call-1", file: "/tmp/child.jsonl" }]); + } + }); + + test("accepts a running subagent-update without a status field", () => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const update = host.mapCustomMessage({ + role: "custom", + customType: "subagent-update", + content: "progress", + details: { id: "native-1", description: "Inspect", message: "progress" }, + }); + expect(update?.subagents).toEqual([ + { type: "upsert", id: "call-1", description: "Inspect", status: "running" }, + ]); + }); test("maps captured foreground lifecycle live and on replay", async () => { const events = await verifySubagentFixture( readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index bf7d2813fb..62db93c79e 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import { outputFileFromToolResult } from "../child-session.js"; +import { GOTGENES_CHILD_SESSION_MARKER, gotgenesRuntimeBridge } from "./runtime-bridge.js"; import type { PiExtension, PiExtensionToolCall } from "../contract.js"; const SpawnArgs = z @@ -22,11 +24,15 @@ const Details = z const Notification = z .object({ id: z.string().trim().min(1), - status: z.string(), + status: z.string().optional(), description: z.string().optional(), outputFile: z.string().trim().min(1).optional(), }) .passthrough(); +const LiveSession = z.object({ + agentId: z.string().trim().min(1), + file: z.string().trim().min(1), +}); const status = (value: string): "running" | "completed" | "failed" | "canceled" => { if (value === "completed") return "completed"; if (value === "error") return "failed"; @@ -36,9 +42,16 @@ const status = (value: string): "running" | "completed" | "failed" | "canceled" export const gotgenesPiSubagents: PiExtension = { id: "@gotgenes/pi-subagents", + runtimeBridge: gotgenesRuntimeBridge, createSession: () => { const callsByAgent = new Map(); const readSessions = new Set(); + const pendingFilesByAgent = new Map(); + const childSession = (id: string, file: string | undefined) => { + if (!file || readSessions.has(file)) return []; + readSessions.add(file); + return [{ id, file }]; + }; const mapSpawn = (call: PiExtensionToolCall) => { const args = SpawnArgs.safeParse(call.args); if (!args.success) return undefined; @@ -75,6 +88,10 @@ export const gotgenesPiSubagents: PiExtension = { } : { detail }; callsByAgent.set(details.data.agentId, call.callId); + const file = + outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); + pendingFilesByAgent.delete(details.data.agentId); + const childSessions = childSession(call.callId, file); return { detail, subagents: [ @@ -87,6 +104,7 @@ export const gotgenesPiSubagents: PiExtension = { status: status(details.data.status), }, ], + childSessions, }; }; const mapFollowup = (call: PiExtensionToolCall) => { @@ -104,11 +122,7 @@ export const gotgenesPiSubagents: PiExtension = { }; if (call.status === "running" || !details.success) return { detail }; const file = details.data.transcriptPath; - const childSessions = - file && status(details.data.status) !== "running" && !readSessions.has(file) - ? [{ id, file }] - : []; - if (childSessions.length && file) readSessions.add(file); + const childSessions = childSession(id, file); return { detail, subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }], @@ -135,23 +149,41 @@ export const gotgenesPiSubagents: PiExtension = { const id = callsByAgent.get(details.data.id); if (!id) return undefined; const file = details.data.outputFile; - const childSessions = - file && status(details.data.status) !== "running" && !readSessions.has(file) - ? [{ id, file }] - : []; - if (childSessions.length && file) readSessions.add(file); + const childSessions = childSession(id, file); return { subagents: [ { type: "upsert", id, description: details.data.description, - status: status(details.data.status), + status: details.data.status ? status(details.data.status) : "running", }, ], childSessions, }; }, + mapRuntimeNotification(message) { + const prefix = `${GOTGENES_CHILD_SESSION_MARKER} `; + if (!message.startsWith(prefix)) return undefined; + let raw: unknown; + try { + raw = JSON.parse(message.slice(prefix.length)); + } catch { + return { subagents: [] }; + } + const parsed = LiveSession.safeParse(raw); + if (!parsed.success) return { subagents: [] }; + const { agentId, file } = parsed.data; + const id = callsByAgent.get(agentId); + if (!id) { + pendingFilesByAgent.set(agentId, file); + return { subagents: [] }; + } + return { + subagents: [{ type: "upsert", id, status: "running" }], + childSessions: childSession(id, file), + }; + }, }; }, }; diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts new file mode 100644 index 0000000000..386fbb4705 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts @@ -0,0 +1,33 @@ +export const GOTGENES_CHILD_SESSION_MARKER = "PASEO_GOTGENES_CHILD_SESSION"; + +/** + * Runs inside Pi. The spawn result omits the child's session file, so the parent's public service + * is the live source. Each child session loads its own copy of gotgenes, which republishes the + * global service and deletes it on exit, so the parent's service is captured at session_start. + */ +export const gotgenesRuntimeBridge = ` + let gotgenesParent; + pi.on("session_start", (_event, ctx) => { + gotgenesParent = { ctx, service: globalThis[Symbol.for("@gotgenes/pi-subagents:service")] }; + }); + pi.events.on("subagents:child:session-created", (event) => { + const parent = gotgenesParent; + let attempts = 0; + // The record gets its session file after the child's extensions bind. + const report = () => { + const record = parent?.service?.listAgents().find((agent) => + agent.outputFile?.includes(event.sessionId), + ); + if (record) { + parent.ctx.ui.notify( + "${GOTGENES_CHILD_SESSION_MARKER} " + + JSON.stringify({ agentId: record.id, file: record.outputFile }), + "info", + ); + } else if (++attempts < 240) { + setTimeout(report, 250); + } + }; + setTimeout(report, 0); + }); +`; diff --git a/packages/server/src/server/agent/providers/pi/extensions/host.ts b/packages/server/src/server/agent/providers/pi/extensions/host.ts index ae0f307393..8915e3df01 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/host.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/host.ts @@ -5,7 +5,7 @@ import type { AgentStreamEvent, } from "../../../agent-sdk-types.js"; import type { PiAgentMessage } from "../rpc-types.js"; -import { mapPiChildSession } from "./child-session.js"; +import { mapPiChildSession, PiChildSessionFollower } from "./child-session.js"; import type { PiExtension, PiExtensionCustomMapping, @@ -30,9 +30,19 @@ interface Session { adapter: PiExtensionSession; } +interface Follower { + reader: PiChildSessionFollower; + /** Reads run in order so a final read cannot overtake a tick. */ + pending: Promise; +} + export class PiExtensionHost { private readonly sessions: Session[] = []; private remainingHydrationBytes: number; + private readonly followers = new Map(); + private onFollowEvent?: (event: AgentStreamEvent) => void; + private followTimer?: ReturnType; + private pollingExtensions = false; constructor( extensions: readonly PiExtension[], @@ -47,6 +57,56 @@ export class PiExtensionHost { } } + /** Live sessions follow child files and adapter polls; replay hosts read each file once. */ + follow(onEvent: (event: AgentStreamEvent) => void): void { + this.onFollowEvent = onEvent; + this.followTimer ??= setInterval(() => { + for (const [id, follower] of this.followers) void this.readFollower(id, follower); + void this.pollExtensions(); + }, 250); + } + + close(): void { + if (this.followTimer) clearInterval(this.followTimer); + this.followTimer = undefined; + this.onFollowEvent = undefined; + this.followers.clear(); + } + + private readFollower(id: string, follower: Follower): Promise { + follower.pending = follower.pending + .then(async () => { + const events = await follower.reader.readNew(); + for (const event of events) { + this.onFollowEvent?.({ type: "provider_subagent", provider: "pi", event }); + } + return undefined; + }) + .catch((error) => { + this.logger?.warn( + { err: error, operation: "follow", childId: id }, + "Pi child follow failed", + ); + }); + return follower.pending; + } + + private async pollExtensions(): Promise { + if (this.pollingExtensions || !this.onFollowEvent) return; + this.pollingExtensions = true; + try { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "poll", () => adapter.poll?.()); + if (!mapping) continue; + const output = this.prepare(id, mapping); + for (const event of output.events) this.onFollowEvent?.(event); + for (const event of await output.hydration) this.onFollowEvent?.(event); + } + } finally { + this.pollingExtensions = false; + } + } + private safe(id: string, operation: string, call: () => T): T | undefined { try { return call(); @@ -80,6 +140,19 @@ export class PiExtensionHost { return undefined; } + mapRuntimeNotification(message: string): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapRuntimeNotification", () => + adapter.mapRuntimeNotification?.(message), + ); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + private prepare( id: string, mapping: T, @@ -92,25 +165,57 @@ export class PiExtensionHost { (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), ), ]; - const hydration = Promise.all( - (mapping.childSessions ?? []).map(async ({ id: childId, file }) => { + const hydration = ( + this.onFollowEvent ? this.followChildren(mapping) : this.readChildren(mapping) + ).catch((error): AgentStreamEvent[] => { + this.logger?.warn( + { err: error, extensionId: id, operation: "hydrate" }, + "Pi extension adapter failed", + ); + return []; + }); + return { ...mapping, events, hydration }; + } + + private async followChildren( + mapping: PiExtensionToolMapping | PiExtensionCustomMapping, + ): Promise { + for (const { id, file } of mapping.childSessions ?? []) { + if (!this.followers.has(id)) { + this.followers.set(id, { + reader: new PiChildSessionFollower(id, file), + pending: Promise.resolve(), + }); + } + } + const finished = (mapping.subagents ?? []).flatMap((event) => + event.type === "upsert" && event.status && event.status !== "running" ? [event.id] : [], + ); + const reads = [ + ...new Set([...(mapping.childSessions ?? []).map(({ id }) => id), ...finished]), + ].flatMap((id) => { + const follower = this.followers.get(id); + return follower ? [this.readFollower(id, follower)] : []; + }); + for (const id of finished) this.followers.delete(id); + await Promise.all(reads); + return []; + } + + private async readChildren( + mapping: PiExtensionToolMapping | PiExtensionCustomMapping, + ): Promise { + const groups = await Promise.all( + (mapping.childSessions ?? []).map(async ({ id, file }) => { const bytes = Math.min(this.remainingHydrationBytes, 2 * 1024 * 1024); if (bytes <= 0) return []; this.remainingHydrationBytes -= bytes; - return (await this.readChildSession(childId, file, bytes)).map( + return (await this.readChildSession(id, file, bytes)).map( (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), ); }), - ) - .then((groups) => groups.flat()) - .catch((error): AgentStreamEvent[] => { - this.logger?.warn( - { err: error, extensionId: id, operation: "hydrate" }, - "Pi extension adapter failed", - ); - return []; - }); - return { ...mapping, events, hydration }; + ); + return groups.flat(); } onToolStart(call: PiExtensionToolCall, provider = "pi"): AgentPermissionRequest | undefined { diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts index dd5d76601a..9afacae977 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "vitest"; +import { mkdtemp, appendFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createPiExtensionHost } from "./index.js"; import { PiHistoryMapper } from "../history-mapper.js"; import { parseToolArgs, parseToolResult } from "../tool-call-mapper.js"; @@ -30,6 +33,53 @@ const throwingAdapter: PiExtension = { }; describe("Pi extension host", () => { + test("streams appended child rows before the child completes", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-live-child-")); + const file = join(dir, "child.jsonl"); + const received: string[] = []; + const host = new PiExtensionHost([ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ + subagents: [{ type: "upsert", id: "child-1", status: "running" }], + childSessions: [{ id: "child-1", file }], + }), + }), + }, + ]); + try { + host.follow((event) => { + if (event.type === "provider_subagent" && event.event.type === "timeline") { + received.push(JSON.stringify(event.event.item)); + } + }); + host.mapToolCall({ + callId: "x", + toolName: "child", + args: {}, + status: "completed", + result: null, + }); + await appendFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"BEGIN"}]}}\n', + ); + await expect.poll(() => received.length).toBe(1); + await appendFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"DO', + ); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(received).toHaveLength(1); + await appendFile(file, 'NE"}]}}\n'); + await expect.poll(() => received.length).toBe(2); + expect(received.join(" ")).toContain("DONE"); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); test("declines a throwing adapter and preserves generic history", async () => { const host = createPiExtensionHost(undefined, [throwingAdapter]); const mapper = new PiHistoryMapper("pi", [], {}, host); diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.ts b/packages/server/src/server/agent/providers/pi/extensions/index.ts index eb4ecda059..2ac68178da 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.ts @@ -3,6 +3,10 @@ import { piExtensions } from "./registry.js"; import type { Logger } from "pino"; import type { PiExtension } from "./contract.js"; +export const piExtensionRuntimeBridge = piExtensions + .flatMap((extension) => extension.runtimeBridge ?? []) + .join("\n"); + export type { PiExtensionHost } from "./host.js"; export type { PiExtensionEventOutput } from "./host.js"; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts index 2f3293f8c1..8e9db54047 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; import { fileURLToPath } from "node:url"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createPiExtensionHost } from "../index.js"; import { parseToolArgs, @@ -27,7 +30,100 @@ function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) { return mapping(toolCall, result)?.detail; } +function hasChildTimeline(events: AgentStreamEvent[]) { + return events.some( + (event) => event.type === "provider_subagent" && event.event.type === "timeline", + ); +} + +function hasCompletedChild(events: AgentStreamEvent[]) { + return events.some( + (event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed", + ); +} + describe("pi-subagents adapter", () => { + test("follows async status and transcript without bg_wait", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-")); + const file = join(dir, "child.jsonl"); + const received: AgentStreamEvent[] = []; + const host = createPiExtensionHost(); + try { + host.follow((event) => received.push(event)); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { agent: "scout", task: "Inspect", async: true }, + status: "completed", + result: { + details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] }, + }, + }); + await writeFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"BEGIN"}]}}\n', + ); + await writeFile( + join(dir, "status.json"), + JSON.stringify({ + state: "running", + steps: [{ agent: "scout", status: "running", sessionFile: file }], + }), + ); + await expect.poll(() => hasChildTimeline(received)).toBe(true); + await writeFile( + join(dir, "status.json"), + JSON.stringify({ + state: "complete", + steps: [{ agent: "scout", status: "complete", sessionFile: file }], + }), + ); + await expect.poll(() => hasCompletedChild(received)).toBe(true); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + test("settles an async run and reads its transcript from the completion notice on replay", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-")); + const file = join(dir, "child.jsonl"); + try { + await writeFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"DONE"}]}}\n', + ); + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { agent: "scout", task: "Inspect", async: true }, + status: "completed", + result: { + details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] }, + }, + }); + const notice = host.mapCustomMessage({ + role: "custom", + customType: "subagent-notify", + content: [ + "Background task completed: **scout**", + "", + "Session file: /tmp/not-the-session-line.jsonl", + "", + `Retention-managed async directory: ${dir}`, + "", + `Session file: ${file}`, + ].join("\n"), + }); + expect(notice?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]); + expect(hasChildTimeline(await notice!.hydration)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); test("maps completed subagent calls with task input to sub-agent detail", () => { const toolCall = parseToolArgs("subagent", { agent: "reviewer", @@ -74,11 +170,11 @@ describe("pi-subagents adapter", () => { expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); }); - test("keeps captured async runs running until structured completion", async () => { + test("settles captured async runs from the completion notice live and on replay", async () => { const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); const events = await verifySubagentFixture(fixture); expect(events.findLast((event) => event.event.type === "upsert")?.event).toEqual( - expect.objectContaining({ status: "running" }), + expect.objectContaining({ status: "completed" }), ); }); @@ -102,7 +198,13 @@ describe("pi-subagents adapter", () => { const upserts = events .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event : null)); - expect(upserts.map((event) => event?.status)).toEqual(["running", "running", "completed"]); + // bg_wait settles the child; the later completion notice repeats it. + expect(upserts.map((event) => event?.status)).toEqual([ + "running", + "running", + "completed", + "completed", + ]); expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); }); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts index 0ec0f0ab47..64e814af3f 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { extractTextFromToolResult } from "../../tool-call-mapper.js"; import type { PiExtension, PiExtensionToolCall, PiExtensionToolMapping } from "../contract.js"; @@ -27,16 +29,55 @@ const Details = z mode: z.string(), runId: z.string().optional(), asyncId: z.string().optional(), + asyncDir: z.string().trim().min(1).optional(), results: z.array(Row), completions: z.array(Completion).optional(), }) .passthrough(); +const AsyncStatus = z + .object({ + state: z.string(), + steps: z.array( + z + .object({ + agent: z.string().trim().min(1), + status: z.string().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(), + ), + }) + .passthrough(); + +function asyncChildStatus(runState: string, stepStatus?: string) { + if (!["complete", "failed", "stopped"].includes(runState)) return "running"; + if (runState === "stopped" || stepStatus === "stopped") return "canceled"; + if (runState === "failed" || stepStatus === "failed" || stepStatus === "error") return "failed"; + return "completed"; +} + +/** The `subagent-notify` header; a grouped notice reports every run as completed. */ +function noticeStatus(header: string) { + if (/^Background tasks completed \(\d+\):/.test(header)) return "completed"; + const outcome = header.match( + /^(?:Background task|Detached foreground task) (completed|failed|stopped): \*\*/, + )?.[1]; + if (outcome === "stopped") return "canceled"; + return outcome as "completed" | "failed" | undefined; +} export const piSubagents: PiExtension = { id: "pi-subagents", createSession: () => { const callsByRun = new Map(); const readSessions = new Set(); + /** Keyed by async directory, which both status.json and the completion notice carry. */ + const asyncRuns = new Map(); + const rememberAsyncRun = (details: z.infer, call: PiExtensionToolCall) => { + if (details.asyncDir && call.status !== "failed") { + asyncRuns.set(details.asyncDir, { owner: call.callId, polling: true }); + } + }; const collectRows = ( owner: string, rows: z.infer[], @@ -109,7 +150,8 @@ export const piSubagents: PiExtension = { ? { detail, subagents: [{ ...base, status: "failed" as const }] } : { detail }; if (details.data.runId) callsByRun.set(details.data.runId, call.callId); - if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) + if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) { + rememberAsyncRun(details.data, call); return { detail, subagents: [ @@ -119,6 +161,7 @@ export const piSubagents: PiExtension = { }, ], }; + } if (details.data.results.length === 0 && call.status === "failed") return { detail, subagents: [{ ...base, status: "failed" as const }] }; return { @@ -132,6 +175,73 @@ export const piSubagents: PiExtension = { if (call.toolName === "subagent") return mapSpawn(call); return undefined; }, + // The notice is the only completion Pi persists, so replay settles async runs from it. + mapCustomMessage(message) { + if (message.customType !== "subagent-notify") return undefined; + const text = + typeof message.content === "string" + ? message.content + : extractTextFromToolResult({ content: message.content }); + const lines = text?.split("\n") ?? []; + const status = noticeStatus(lines[0] ?? ""); + if (!status) return undefined; + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + // Each run's session line follows its async directory line; earlier lines are child output. + let owner: string | undefined; + for (const line of lines) { + const dir = line.match(/^Retention-managed async directory: (.+)$/)?.[1]; + if (dir) { + owner = asyncRuns.get(dir)?.owner; + if (owner) subagents.push({ type: "upsert", id: owner, status }); + continue; + } + const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined; + if (!owner || !file) continue; + if (!readSessions.has(file)) { + readSessions.add(file); + childSessions.push({ id: owner, file }); + } + owner = undefined; + } + return subagents.length ? { subagents, childSessions } : undefined; + }, + poll() { + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const [dir, run] of asyncRuns) { + if (!run.polling) continue; + let raw: unknown; + try { + raw = JSON.parse(readFileSync(join(dir, "status.json"), "utf8")); + } catch { + continue; + } + const parsed = AsyncStatus.safeParse(raw); + if (!parsed.success) continue; + const terminal = ["complete", "failed", "stopped"].includes(parsed.data.state); + for (const [index, step] of parsed.data.steps.entries()) { + const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`; + const status = asyncChildStatus(parsed.data.state, step.status); + subagents.push({ type: "upsert", id, title: step.agent, status }); + if (step.sessionFile && !readSessions.has(step.sessionFile)) { + readSessions.add(step.sessionFile); + childSessions.push({ id, file: step.sessionFile }); + } + } + if (terminal) { + if (parsed.data.steps.length === 0) { + subagents.push({ + type: "upsert", + id: run.owner, + status: parsed.data.state === "complete" ? "completed" : "failed", + }); + } + run.polling = false; + } + } + return subagents.length || childSessions.length ? { subagents, childSessions } : undefined; + }, }; }, }; diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json new file mode 100644 index 0000000000..083e57744e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json @@ -0,0 +1,419 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-10-01" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_277173", + "toolName": "Agent", + "args": { + "run_in_background": true, + "description": "Test agent 1", + "prompt": "reply with exactly OK", + "subagent_type": "general-purpose" + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_277176", + "toolName": "Agent", + "args": { + "prompt": "reply with exactly OK", + "run_in_background": true, + "description": "Test agent 2", + "subagent_type": "general-purpose" + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_277179", + "toolName": "Agent", + "args": { + "subagent_type": "general-purpose", + "description": "Test agent 3", + "prompt": "reply with exactly OK", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277173", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 4b4fd01d-aaa8-46d\nType: Agent\nDescription: Test agent 1\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 1", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "4b4fd01d-aaa8-46d" + } + }, + "isError": false + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277176", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: a92b022c-cd9e-41e\nType: Agent\nDescription: Test agent 2\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 2", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "a92b022c-cd9e-41e" + } + }, + "isError": false + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277179", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 13336b9f-79c3-49e\nType: Agent\nDescription: Test agent 3\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 3", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "13336b9f-79c3-49e" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "Background agent group completed: 3 agent(s) finished\n\n\n4b4fd01d-aaa8-46d\ncall_277173\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\nDone\nAgent \"Test agent 1\" completed\nOK\n10186011054\n\n\n\na92b022c-cd9e-41e\ncall_277176\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\nDone\nAgent \"Test agent 2\" completed\nOK\n10186011182\n\n\n\n13336b9f-79c3-49e\ncall_277179\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\nDone\nAgent \"Test agent 3\" completed\nOK\n10186011472\n\n\nUse get_subagent_result for full output.", + "display": true, + "details": { + "id": "4b4fd01d-aaa8-46d", + "description": "Test agent 1", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1054, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output", + "resultPreview": "OK", + "others": [ + { + "id": "a92b022c-cd9e-41e", + "description": "Test agent 2", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1182, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output", + "resultPreview": "OK" + }, + { + "id": "13336b9f-79c3-49e", + "description": "Test agent 3", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1472, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output", + "resultPreview": "OK" + } + ] + }, + "timestamp": 1790833737082 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "", + "thinkingSignature": "[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19lxhI31c8Kjto2eEqQuEoAa1jXk+6WHw4RxcJiLq9eTkm6UtOU0r9mP+xFrs3A9GkNyIfpaIzLUGuPKjiYw648QdaOOVbR7jCqPVFA+CEYQH0V\",\"format\":\"google-gemini-v1\",\"id\":\"call_277173\",\"index\":0}]" + }, + { + "type": "toolCall", + "id": "call_277173", + "name": "Agent", + "arguments": { + "run_in_background": true, + "description": "Test agent 1", + "prompt": "reply with exactly OK", + "subagent_type": "general-purpose" + } + }, + { + "type": "toolCall", + "id": "call_277176", + "name": "Agent", + "arguments": { + "prompt": "reply with exactly OK", + "run_in_background": true, + "description": "Test agent 2", + "subagent_type": "general-purpose" + } + }, + { + "type": "toolCall", + "id": "call_277179", + "name": "Agent", + "arguments": { + "subagent_type": "general-purpose", + "description": "Test agent 3", + "prompt": "reply with exactly OK", + "run_in_background": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13147, + "output": 135, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13282, + "cost": { + "input": 0.009860250000000001, + "output": 0.00050625, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.0103665 + } + }, + "stopReason": "toolUse", + "timestamp": 1790833733536, + "responseId": "gen-1790833733-Qo0mjOAU6129xnYfl8rM", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_277173", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 4b4fd01d-aaa8-46d\nType: Agent\nDescription: Test agent 1\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 1", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "4b4fd01d-aaa8-46d" + }, + "isError": false, + "timestamp": 1790833735411 + }, + { + "role": "toolResult", + "toolCallId": "call_277176", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: a92b022c-cd9e-41e\nType: Agent\nDescription: Test agent 2\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 2", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "a92b022c-cd9e-41e" + }, + "isError": false, + "timestamp": 1790833735412 + }, + { + "role": "toolResult", + "toolCallId": "call_277179", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 13336b9f-79c3-49e\nType: Agent\nDescription: Test agent 3\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 3", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "13336b9f-79c3-49e" + }, + "isError": false, + "timestamp": 1790833735412 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I have spawned the 3 background agents and am waiting for their completion notifications." + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2004, + "output": 16, + "cacheRead": 11743, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13763, + "cost": { + "input": 0.001503, + "output": 6e-5, + "cacheRead": 0.000880725, + "cacheWrite": 0, + "total": 0.002443725 + } + }, + "stopReason": "stop", + "timestamp": 1790833735412, + "responseId": "gen-1790833735-adM3whTd4mLRXnKi2Xu0", + "rawStopReason": "stop" + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "Background agent group completed: 3 agent(s) finished\n\n\n4b4fd01d-aaa8-46d\ncall_277173\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\nDone\nAgent \"Test agent 1\" completed\nOK\n10186011054\n\n\n\na92b022c-cd9e-41e\ncall_277176\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\nDone\nAgent \"Test agent 2\" completed\nOK\n10186011182\n\n\n\n13336b9f-79c3-49e\ncall_277179\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\nDone\nAgent \"Test agent 3\" completed\nOK\n10186011472\n\n\nUse get_subagent_result for full output.", + "display": true, + "details": { + "id": "4b4fd01d-aaa8-46d", + "description": "Test agent 1", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1054, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output", + "resultPreview": "OK", + "others": [ + { + "id": "a92b022c-cd9e-41e", + "description": "Test agent 2", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1182, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output", + "resultPreview": "OK" + }, + { + "id": "13336b9f-79c3-49e", + "description": "Test agent 3", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1472, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output", + "resultPreview": "OK" + } + ] + }, + "timestamp": 1790833737082 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2740, + "output": 1, + "cacheRead": 11762, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 14503, + "cost": { + "input": 0.002055, + "output": 3.75e-6, + "cacheRead": 0.00088215, + "cacheWrite": 0, + "total": 0.0029408999999999998 + } + }, + "stopReason": "stop", + "timestamp": 1790833737083, + "responseId": "gen-1790833737-3IfsB2wLuE8DMNlJMgmI", + "rawStopReason": "stop" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts index 62a8e5ea26..b03c17c6ec 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -4,6 +4,24 @@ import { createPiExtensionHost } from "../index.js"; import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; describe("@tintinweb/pi-subagents adapter", () => { + test("exposes the background output file while the child is running", () => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "background" }, + content: [ + { type: "text", text: "Agent started in background.\nOutput file: /tmp/child.output\n" }, + ], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "running" }), + ]); + expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]); + }); test("maps captured foreground lifecycle live and on replay", async () => { const events = await verifySubagentFixture( readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), @@ -33,6 +51,20 @@ describe("@tintinweb/pi-subagents adapter", () => { ).toEqual(["running", "running", "completed"]); expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); }); + test("completes every background child in a grouped notification", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/background-group.json", import.meta.url)), + ); + const finalStatus = new Map(); + for (const { event } of events) { + if (event.type === "upsert" && event.status) finalStatus.set(event.id, event.status); + } + expect(Object.fromEntries(finalStatus)).toEqual({ + call_277173: "completed", + call_277176: "completed", + call_277179: "completed", + }); + }); test("declines foreign Agent results", () => { expect( createPiExtensionHost().mapToolCall({ diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts index dcfe093328..9c6ef9917e 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import { outputFileFromToolResult } from "../child-session.js"; import type { PiExtension, PiExtensionToolCall } from "../contract.js"; const SpawnArgs = z @@ -18,7 +19,7 @@ const Details = z description: z.string().optional(), }) .passthrough(); -const Notification = z +const NotificationEntry = z .object({ id: z.string().trim().min(1), status: z.string(), @@ -26,6 +27,11 @@ const Notification = z outputFile: z.string().trim().min(1).optional(), }) .passthrough(); +// Completions that land close together arrive as one notification: the first +// agent at the top level and the rest in `others`. +const Notification = NotificationEntry.extend({ + others: z.array(NotificationEntry).optional(), +}); const status = (value: string): "running" | "completed" | "failed" | "canceled" => { if (value === "completed") return "completed"; if (value === "error") return "failed"; @@ -74,6 +80,9 @@ export const tintinwebPiSubagents: PiExtension = { } : { detail }; callsByAgent.set(details.data.agentId, call.callId); + const file = outputFileFromToolResult(call.result); + const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : []; + if (file) readSessions.add(file); return { detail, subagents: [ @@ -86,6 +95,7 @@ export const tintinwebPiSubagents: PiExtension = { status: status(details.data.status), }, ], + childSessions, }; }; const mapFollowup = (call: PiExtensionToolCall) => { @@ -122,25 +132,25 @@ export const tintinwebPiSubagents: PiExtension = { return undefined; const details = Notification.safeParse(message.details); if (!details.success) return undefined; - const id = callsByAgent.get(details.data.id); - if (!id) return undefined; - const file = details.data.outputFile; - const childSessions = - file && status(details.data.status) !== "running" && !readSessions.has(file) - ? [{ id, file }] - : []; - if (childSessions.length && file) readSessions.add(file); - return { - subagents: [ - { - type: "upsert", - id, - description: details.data.description, - status: status(details.data.status), - }, - ], - childSessions, - }; + const subagents = []; + const childSessions = []; + for (const entry of [details.data, ...(details.data.others ?? [])]) { + const id = callsByAgent.get(entry.id); + if (!id) continue; + const file = entry.outputFile; + if (file && !readSessions.has(file)) { + readSessions.add(file); + childSessions.push({ id, file }); + } + subagents.push({ + type: "upsert" as const, + id, + description: entry.description, + status: status(entry.status), + }); + } + if (subagents.length === 0) return undefined; + return { subagents, childSessions }; }, }; }, 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/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts index 281a2bcf32..cd0e4776b7 100644 --- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -7,6 +7,7 @@ const FAILED_STATUS_VOCAB = new Set([ "errored", "rejected", "denied", + "declined", ]); const CANCELED_STATUS_VOCAB = new Set(["canceled", "cancelled", "interrupted", "aborted"]); const COMPLETED_STATUS_VOCAB = new Set(["completed", "complete", "done", "success", "succeeded"]); diff --git a/packages/server/src/server/agent/providers/trae-acp-agent.ts b/packages/server/src/server/agent/providers/trae-acp-agent.ts index b1e0893e4b..c14525211e 100644 --- a/packages/server/src/server/agent/providers/trae-acp-agent.ts +++ b/packages/server/src/server/agent/providers/trae-acp-agent.ts @@ -8,7 +8,6 @@ interface TraeACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -21,7 +20,6 @@ export class TraeACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // traecli publishes slash commands and skills asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index fa260011c3..e7e77bc0d3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -150,7 +150,6 @@ const INBOUND_PERMISSION = { project_icon_request: "workspace.read", "provider.usage.list.request": "daemon.read", "usage.list_reports.request": "daemon.read", - "agent.resolve_usage_report.request": "daemon.read", provider_diagnostic_request: "daemon.read", pull_request_timeline_request: "workspace.read", "push.unregister.request": "workspace.read", @@ -375,8 +374,8 @@ 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", - "agent.resolve_usage_report.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], pull_request_timeline_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts index 348a6cf0eb..3822cfaa55 100644 --- a/packages/server/src/server/bootstrap-web-ui.test.ts +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -2,10 +2,17 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +const expectedHostName = + process.platform === "darwin" + ? execFileSync("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8" }).trim() + : os.hostname(); + interface InitialDaemonConnectionHint { listen: string; useTls: boolean; @@ -77,6 +84,23 @@ describe("daemon web UI bootstrap", () => { } }); + test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => { + daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false }); + const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); + expect(await response.json()).toMatchObject({ hostname: expectedHostName }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemonHandle.port}/ws`, + clientId: "host-name-test", + clientType: "mobile", + }); + try { + await client.connect(); + expect(client.getLastServerInfoMessage()?.hostname).toBe(expectedHostName); + } finally { + await client.close(); + } + }); + test("injects a TLS initial connection hint only for HTTPS forwarded by a trusted proxy", async () => { const distDir = await createWebUiDist(); @@ -101,12 +125,12 @@ describe("daemon web UI bootstrap", () => { expect(httpHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: false, - label: os.hostname(), + label: expectedHostName, }); expect(httpsHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: true, - label: os.hostname(), + label: expectedHostName, }); }); @@ -132,7 +156,7 @@ describe("daemon web UI bootstrap", () => { expect(httpsHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: false, - label: os.hostname(), + label: expectedHostName, }); }); }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 695d7b25cb..3f49f41a19 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,10 +1,11 @@ +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"; import { constants, existsSync, unlinkSync } from "fs"; import { open, rm, stat } from "fs/promises"; import { randomUUID } from "node:crypto"; -import { hostname as getHostname } from "node:os"; +import { getHostName } from "./host-name.js"; import path from "node:path"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { Logger } from "pino"; @@ -411,6 +412,8 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -523,7 +526,7 @@ function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger: createWebUiMiddleware({ enabled: config.webUi?.enabled ?? false, distDir: config.webUi?.distDir ?? null, - label: getHostname(), + label: getHostName(), logger, }), ); @@ -615,8 +618,15 @@ 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, { + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); @@ -794,7 +804,7 @@ export async function createPaseoDaemon( res.json({ status: "server_info", serverId, - hostname: getHostname(), + hostname: getHostName(), version: daemonVersion, listen: formatListenTarget(boundListenTarget ?? listenTarget), }); @@ -1160,7 +1170,6 @@ export async function createPaseoDaemon( emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, serviceProxy, scriptRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), @@ -1235,7 +1244,7 @@ export async function createPaseoDaemon( }); const hubRelationships = new HubRelationshipController({ paseoHome: config.paseoHome, - hostname: getHostname(), + hostname: getHostName(), serverId, daemonPublicKey: daemonKeyPair.publicKeyB64, logger, @@ -1411,9 +1420,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), @@ -1734,6 +1742,7 @@ export async function createPaseoDaemon( ); pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); + providerSnapshotManager.settlePluginProviders(); wsServer.beginAcceptingConnections(); relayRuntime = createRelayRuntime({ config: { @@ -1806,6 +1815,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..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -29,6 +29,7 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_COUNT", "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", + "PASEO_PLUGIN_REGISTRY", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 80045a4454..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,18 @@ describe("server config", () => { ).toBe(path.join(packageRoot, "app-dist")); }); }); + +test("loads private plugin registry settings through the configuration boundary", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); + try { + const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); + const config = loadConfig(home, { + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, + }); + expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); + expect(config.pluginRegistries).toEqual(pluginRegistries); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index a683ec69fb..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -626,6 +626,8 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, plugins: persisted.plugins, + pluginRegistries: persisted.pluginRegistries, + pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 7691e63713..1f2086c879 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -1,4 +1,11 @@ import { describe, test, expect } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { findExecutable } from "../../executable-resolution/executable-resolution.js"; +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; import { execFileSync } from "node:child_process"; import { createDaemonTestContext } from "../test-utils/index.js"; @@ -96,3 +103,65 @@ describe("daemon E2E", () => { ); }); }); + +test("Antigravity creates an agent and answers a prompt with real agy", async ({ skip }) => { + const command = await findExecutable(process.env.AGY_COMMAND ?? "agy"); + if (command === null) { + skip("agy is not resolvable"); + return; + } + const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-antigravity-e2e-")); + const daemon = await createTestPaseoDaemon({ + agentClients: {}, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, ["antigravity-provider"]), + providerOverrides: { antigravity: { command: [command], paseoTools: { enabled: false } } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await expect + .poll( + async () => { + const snapshot = await client.getProvidersSnapshot({ cwd }); + return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status; + }, + { timeout: 30000 }, + ) + .toBe("ready"); + const agent = await client.createAgent({ + provider: "antigravity", + cwd, + title: "Antigravity E2E", + }); + expect(agent.currentModeId).toBe("full-access"); + expect(agent.availableModes?.map((mode) => mode.id)).toEqual(["full-access"]); + const openedTimeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect(openedTimeline.entries.map((entry) => entry.item)).toEqual([ + { + type: "notification", + level: "warning", + message: + "Antigravity is running with full access\nAntigravity's CLI cannot ask for permission when another app drives it, so Paseo starts it with --dangerously-skip-permissions. Every tool call, including shell commands, runs without asking.", + }, + ]); + await client.sendMessage(agent.id, "Reply with exactly ANTIGRAVITY_E2E_OK. No tools."); + const result = await client.waitForFinish(agent.id, 90000); + expect(result.status).toBe("idle"); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect( + timeline.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("ANTIGRAVITY_E2E_OK"), + ), + ).toBe(true); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 120000); diff --git a/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts new file mode 100644 index 0000000000..f8355065f4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { isCommandAvailable } from "../../executable-resolution/executable-resolution.js"; +import { loadPersistedConfig } from "../persisted-config.js"; +import { resolvePaseoHome } from "../paseo-home.js"; +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("Muse registers without a client settings bundle", async () => { + const daemon = await createTestPaseoDaemon({ + builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + const muse = (await client.getPluginCatalog()).find((plugin) => plugin.id === "muse-provider"); + expect(muse).toBeDefined(); + expect(muse?.clientBundle).toBe(""); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("Muse creates an agent, streams a text reply, and executes a real tool", async (context) => { + if (!(await isCommandAvailable("muse"))) { + context.skip(); + return; + } + const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-muse-e2e-")); + await writeFile(path.join(cwd, "f"), "MUSE_TOOL_E2E_OK\n"); + execFileSync("git", ["init", cwd], { stdio: "ignore" }); + const configured = loadPersistedConfig(resolvePaseoHome()).agents?.providers?.muse; + const daemon = await createTestPaseoDaemon({ + builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]), + providerOverrides: { muse: { ...configured, enabled: true } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider: "muse", cwd, modeId: "allowAll" }); + await client.sendMessage(agent.id, "Reply exactly MUSE_TEXT_E2E_OK. Do not use tools."); + expect((await client.waitForFinish(agent.id, 120000)).status).toBe("idle"); + const reply = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + reply.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("MUSE_TEXT_E2E_OK"), + ), + ).toBe(true); + await client.sendMessage( + agent.id, + "Read the file f with the read_file tool and report its contents.", + ); + expect((await client.waitForFinish(agent.id, 120000)).status).toBe("idle"); + const tools = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + tools.entries.some( + (entry) => + entry.item.type === "tool_call" && + entry.item.name === "read_file" && + entry.item.status === "completed", + ), + ).toBe(true); + expect( + tools.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("MUSE_TOOL_E2E_OK"), + ), + ).toBe(true); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 300000); 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/host-name.ts b/packages/server/src/server/host-name.ts new file mode 100644 index 0000000000..5f36dcc368 --- /dev/null +++ b/packages/server/src/server/host-name.ts @@ -0,0 +1,14 @@ +import { execFileSync } from "node:child_process"; +import { hostname } from "node:os"; + +let hostName: string | undefined; + +// macOS's kernel hostname comes from DHCP/reverse DNS and can be "UNKNOWN"; +// ComputerName is the name the user gave the Mac. +export function getHostName(): string { + hostName ??= + process.platform === "darwin" + ? execFileSync("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8" }).trim() + : hostname(); + return hostName; +} diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index a96dc8cfdd..d78b29d732 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -1,3 +1,7 @@ +import type { AgentClient } from "../agent/agent-sdk-types.js"; +import { validateProviderOptions } from "../agent/provider-options.js"; +import { CodexProviderOptionsSchema } from "../agent/providers/codex/options.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { afterEach, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; @@ -8,8 +12,12 @@ afterEach(async () => { relationship = null; }); -async function launchRelationship(): Promise { - const launched = await HubRelationshipHarness.start(); +async function launchRelationship( + clients?: Record, +): Promise { + const launched = clients + ? await HubRelationshipHarness.startWithRealAgentClients(clients) + : await HubRelationshipHarness.start(); await launched.beginConnect().result; launched.connectLatestSocket(); relationship = launched; @@ -127,8 +135,22 @@ test("Hub can preapprove only tools on MCP servers injected in the same request" expect(hub.providerCreations()).toBe(0); }); -test("Hub returns path-specific structured provider option feedback", async () => { - const hub = await launchRelationship(); +test("Hub returns path-specific feedback thrown by the provider", async () => { + const clients = createTestAgentClients(); + const inner = clients.codex; + const provider: AgentClient = { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: (signal, options) => inner.isAvailable(signal, options), + fetchCatalog: (options, context) => inner.fetchCatalog(options, context), + async createSession(config, launch, options) { + validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions); + return inner.createSession(config, launch, options); + }, + resumeSession: (handle, overrides, launch, options) => + inner.resumeSession(handle, overrides, launch, options), + }; + const hub = await launchRelationship({ ...clients, codex: provider }); hub.beginOwnedCreate("invalid-options", "invalid-options-execution", { providerOptions: { sandbox_workspace_write: { writable_roots: ["/tmp", 42] }, diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index f632971cc9..51596b2384 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; -import { hostname, platform } from "node:os"; +import { platform } from "node:os"; import { afterEach, describe, expect, test } from "vitest"; +import { getHostName } from "../host-name.js"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; async function captureUnhandledRejections(action: () => Promise): Promise { @@ -88,7 +89,7 @@ describe("Hub relationship", () => { .update(secret ?? "") .digest("base64url"), ); - expect(enrollment.hostname).toBe(hostname()); + expect(enrollment.hostname).toBe(getHostName()); relationship.completeEnrollment(); await connecting.result; await relationship.socketDialed(); diff --git a/packages/server/src/server/orchestration-skills/internal/controller.test.ts b/packages/server/src/server/orchestration-skills/internal/controller.test.ts index d3dab5aa78..23b541e67d 100644 --- a/packages/server/src/server/orchestration-skills/internal/controller.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/controller.test.ts @@ -210,7 +210,7 @@ async function blockAgentsDir(targets: SkillTargets): Promise { } async function isInstalled(targets: SkillTargets, name: string): Promise { - const dirs = [targets.agentsDir, targets.claudeDir, targets.codexDir]; + const dirs = [targets.agentsDir, targets.claudeDir]; const present = await Promise.all( dirs.map((dir) => access(path.join(dir, name)) @@ -454,7 +454,7 @@ describe("skills controller", () => { expect(await installedEverywhere(readOnly.targets)).toEqual([ ["paseo", "paseo-loop"], ["paseo", "paseo-loop"], - ["paseo", "paseo-loop"], + ["paseo-loop"], ]); expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/mine.md")).toEqual([ "hand written", @@ -575,6 +575,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); const livePaths = [ harness.targets.agentsDir, harness.targets.claudeDir, @@ -601,6 +602,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ { kind: "delete", name: "paseo-loop" }, @@ -666,6 +668,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "staged notes"); const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ @@ -1115,7 +1118,7 @@ describe("skills controller", () => { expect(await installedEverywhere(harness.targets)).toEqual([ ["paseo", "paseo-advisor", "paseo-loop"], ["paseo", "paseo-advisor", "paseo-loop"], - ["paseo", "paseo-advisor", "paseo-loop"], + ["paseo-advisor"], ]); expect(result.selection).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); }); @@ -1131,7 +1134,7 @@ describe("skills controller", () => { expect(result.confirmationRequired).toBeNull(); expect(result.selection).toEqual({ mode: "custom", skills: ["paseo"] }); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); }); it("asks again when another directory appears before the retry", async () => { @@ -1150,7 +1153,7 @@ describe("skills controller", () => { expect(await installedEverywhere(harness.targets)).toEqual([ ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], - ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], + ["paseo-chat"], ]); }); @@ -1179,7 +1182,7 @@ describe("skills controller", () => { const result = await harness.controller.save({ mode: "custom", skills: ["paseo"] }); expect(result.confirmationRequired).toBeNull(); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); }); it("preserves a regular file at a skill path when save convergence fails", async () => { @@ -1211,7 +1214,7 @@ describe("skills controller", () => { ]); expect(saved.selection).toEqual({ mode: "custom", skills: ["paseo"] }); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); expect(await harness.controller.status()).toEqual({ state: "up-to-date", ops: [], diff --git a/packages/server/src/server/orchestration-skills/internal/operations.test.ts b/packages/server/src/server/orchestration-skills/internal/operations.test.ts index 6b188d24b0..14363c4b42 100644 --- a/packages/server/src/server/orchestration-skills/internal/operations.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/operations.test.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -298,8 +299,8 @@ describe("custom skill selection", () => { available: ["paseo", "paseo-advisor", "paseo-loop"], installed: ["paseo", "paseo-loop"], }); - expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); - expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, false]); + expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, false]); expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); }); @@ -322,7 +323,7 @@ describe("custom skill selection", () => { const status = await installSkills(sandbox.targets, only("paseo")); expect(status.state).toBe("up-to-date"); - expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, false]); expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]); expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); }); @@ -395,7 +396,7 @@ describe("installSkills / updateSkills", () => { await fs.rm(sandbox.root, { recursive: true, force: true }); }); - it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => { + it("installs into shared and Claude roots without duplicating Codex discovery", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" }); @@ -411,9 +412,7 @@ describe("installSkills / updateSkills", () => { expect( await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"), ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); - expect( - await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"), - ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); + expect(await pathExists(path.join(sandbox.targets.codexDir, name, "SKILL.md"))).toBe(false); expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true); } expect( @@ -421,6 +420,100 @@ describe("installSkills / updateSkills", () => { ).toBe("user-unslop"); }); + it("retires unchanged managed Codex copies while preserving personal files", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await installSkills(sandbox.targets, ALL_SKILLS); + const legacy = path.join(sandbox.targets.codexDir, "paseo"); + const managed = { "SKILL.md": "paseo-v1", "references/old.md": "bundled reference" }; + const hashes = Object.fromEntries( + Object.entries(managed).map(([name, content]) => [ + name, + createHash("sha256").update(content).digest("hex"), + ]), + ); + await writeFiles(legacy, { + ...managed, + "notes/mine.md": "personal notes", + ".paseo-managed-files.json": JSON.stringify({ version: 1, files: hashes }), + }); + const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + expect(status.state).toBe("up-to-date"); + expect(status.ops).toEqual([]); + expect(await pathExists(path.join(legacy, "SKILL.md"))).toBe(false); + expect(await pathExists(path.join(legacy, "references/old.md"))).toBe(false); + expect(await fs.readFile(path.join(legacy, "notes/mine.md"), "utf8")).toBe("personal notes"); + expect(await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS)).toEqual(status); + }); + + it("keeps edited or untracked Codex skill definitions", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await installSkills(sandbox.targets, ALL_SKILLS); + const originalHash = createHash("sha256").update("paseo-v1").digest("hex"); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { + "SKILL.md": "personalized skill", + ".paseo-managed-files.json": JSON.stringify({ + version: 1, + files: { "SKILL.md": originalHash }, + }), + }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { + "SKILL.md": "untracked skill", + }); + expect((await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS)).state).toBe("up-to-date"); + expect( + await fs.readFile(path.join(sandbox.targets.codexDir, "paseo", "SKILL.md"), "utf8"), + ).toBe("personalized skill"); + expect( + await fs.readFile(path.join(sandbox.targets.codexDir, "paseo-loop", "SKILL.md"), "utf8"), + ).toBe("untracked skill"); + }); + + it.each([ + { alias: "codex", target: "agents" }, + { alias: "codex", target: "claude" }, + { alias: "claude", target: "codex" }, + ] as const)( + "keeps installed skills when the $alias root links to the $target root", + async ({ alias, target }) => { + await writeCurrentBundle(sandbox.targets.sourceDir); + const rootOf = { agents: "agentsDir", claude: "claudeDir", codex: "codexDir" } as const; + const aliasRoot = sandbox.targets[rootOf[alias]]; + const targetRoot = sandbox.targets[rootOf[target]]; + await fs.mkdir(targetRoot, { recursive: true }); + await fs.mkdir(path.dirname(aliasRoot), { recursive: true }); + await fs.symlink(targetRoot, aliasRoot, "dir"); + + const installed = await installSkills(sandbox.targets, ALL_SKILLS); + const updated = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + + expect(installed.state).toBe("up-to-date"); + expect(updated.state).toBe("up-to-date"); + for (const name of ["paseo", "paseo-loop"]) { + expect(await installedIn(sandbox.targets, name)).toEqual([true, true, true]); + } + }, + ); + + it("keeps an installed skill when its Codex directory links to the shared copy", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await fs.mkdir(path.join(sandbox.targets.agentsDir, "paseo"), { recursive: true }); + await fs.mkdir(sandbox.targets.codexDir, { recursive: true }); + await fs.symlink( + path.join(sandbox.targets.agentsDir, "paseo"), + path.join(sandbox.targets.codexDir, "paseo"), + "dir", + ); + + const installed = await installSkills(sandbox.targets, ALL_SKILLS); + const updated = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + + expect(installed.state).toBe("up-to-date"); + expect(updated.state).toBe("up-to-date"); + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf8"), + ).toBe("paseo-v1"); + }); + it("repairs missing and edited skills without deleting a legacy directory", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); diff --git a/packages/server/src/server/orchestration-skills/internal/operations.ts b/packages/server/src/server/orchestration-skills/internal/operations.ts index a3b0831122..e45878a953 100644 --- a/packages/server/src/server/orchestration-skills/internal/operations.ts +++ b/packages/server/src/server/orchestration-skills/internal/operations.ts @@ -2,7 +2,13 @@ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; -import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js"; +import { + isLegacyCodexCopy, + listFilesRecursive, + readManagedFilesManifest, + removeSkill, + syncSkills, +} from "./sync.js"; export type SkillsState = "not-installed" | "up-to-date" | "drift"; @@ -163,11 +169,23 @@ export async function getSkillsStatus( hashSkills(targets.claudeDir, names), hashSkills(targets.codexDir, names), ]); - const disks = [agentsDisk, claudeDisk, codexDisk]; - const ops = diff(bundle, disks, names, resolveDesiredSkills(selection, available)); - const installed = installedSkillNames(disks, names); + const disks = [agentsDisk, claudeDisk]; + const desired = resolveDesiredSkills(selection, available); + const ops = diff(bundle, disks, names, desired); + for (const name of codexDisk.keys()) { + if (ops.some((op) => op.name === name)) continue; + if (!(await isLegacyCodexCopy(name, targets))) continue; + if (!desired.has(name)) ops.push({ kind: "delete", name }); + else if (await readManagedFilesManifest(path.join(targets.codexDir, name))) { + ops.push({ kind: "update", name }); + } + } + ops.sort((a, b) => compareStrings(a.name, b.name)); + const allDisks = [...disks, codexDisk]; + const installed = installedSkillNames(allDisks, names); - if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops, available, installed }; + if (!hasInstalledPaseoSkill(allDisks)) + return { state: "not-installed", ops, available, installed }; if (ops.length === 0) return { state: "up-to-date", ops, available, installed }; return { state: "drift", ops, available, installed }; } diff --git a/packages/server/src/server/orchestration-skills/internal/sync.test.ts b/packages/server/src/server/orchestration-skills/internal/sync.test.ts index da5b1c8765..1848330fdb 100644 --- a/packages/server/src/server/orchestration-skills/internal/sync.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/sync.test.ts @@ -75,11 +75,9 @@ describe("syncSkills", () => { "utf-8", ); expect(claudeContent).toBe("new paseo content"); - const codexContent = await fs.readFile( - path.join(sandbox.codexDir, "paseo", "SKILL.md"), - "utf-8", - ); - expect(codexContent).toBe("new paseo content"); + await expect(fs.stat(path.join(sandbox.codexDir, "paseo", "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("installs new bundled skills, including references/, when not present on disk", async () => { @@ -105,12 +103,9 @@ describe("syncSkills", () => { "utf-8", ), ).toBe("roles content"); - expect( - await fs.readFile( - path.join(sandbox.codexDir, "paseo-committee", "references", "roles.md"), - "utf-8", - ), - ).toBe("roles content"); + await expect(fs.stat(path.join(sandbox.codexDir, "paseo-committee"))).rejects.toMatchObject({ + code: "ENOENT", + }); const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-committee"); expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true); diff --git a/packages/server/src/server/orchestration-skills/internal/sync.ts b/packages/server/src/server/orchestration-skills/internal/sync.ts index 9233365549..255c61a274 100644 --- a/packages/server/src/server/orchestration-skills/internal/sync.ts +++ b/packages/server/src/server/orchestration-skills/internal/sync.ts @@ -51,7 +51,9 @@ export async function listFilesRecursive(rootDir: string): Promise { return out; } -async function readManagedFilesManifest(dstDir: string): Promise { +export async function readManagedFilesManifest( + dstDir: string, +): Promise { const raw = await fs .readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8") .catch(() => null); @@ -185,6 +187,50 @@ export async function removeSkill(skillName: string, targets: RemoveSkillTargets } } +async function isSameDirectory(a: string, b: string): Promise { + const [realA, realB] = await Promise.all([a, b].map((p) => fs.realpath(p).catch(() => null))); + return realA !== null && realA === realB; +} + +/** + * Codex also discovers .agents/skills, so its dedicated copy is a leftover. When + * the Codex skill directory resolves to the shared or Claude copy, it is that + * copy, and retiring it would delete the install. + */ +export async function isLegacyCodexCopy( + skillName: string, + targets: RemoveSkillTargets, +): Promise { + const codexSkillDir = path.join(targets.codexDir, skillName); + for (const root of [targets.agentsDir, targets.claudeDir]) { + if (await isSameDirectory(codexSkillDir, path.join(root, skillName))) return false; + } + return true; +} + +async function retireManagedSkill(dstDir: string): Promise { + const info = await fs.lstat(dstDir).catch(() => null); + if (!info?.isDirectory()) return 0; + const manifest = await readManagedFilesManifest(dstDir); + if (!manifest) return 0; + const files = Object.keys(manifest.files); + await assertManagedPathsStayInsideSkill(dstDir, [MANAGED_FILES_MANIFEST, ...files]); + const removed: string[] = []; + for (const [rel, previousHash] of Object.entries(manifest.files)) { + const filePath = path.join(dstDir, rel); + const currentHash = await hashFile(filePath).catch(() => null); + if (currentHash !== previousHash) continue; + await fs.rm(filePath); + removed.push(rel); + } + await pruneEmptyParentDirs(dstDir, removed); + await fs.rm(path.join(dstDir, MANAGED_FILES_MANIFEST)); + await fs.rmdir(dstDir).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOTEMPTY" && error.code !== "ENOENT") throw error; + }); + return removed.length + 1; +} + export async function syncSkills(options: SkillSyncOptions): Promise { let changedFiles = 0; let processedSkills = 0; @@ -206,10 +252,10 @@ export async function syncSkills(options: SkillSyncOptions): Promise { expect(parsed.agents?.providers?.claude?.order).toBe(1); }); - test("new provider without extends → error", () => { - const result = PersistedConfigSchema.safeParse({ - agents: { - providers: { - zai: { - label: "ZAI", - }, - }, - }, + test("accepts a plugin override before plugin registration", () => { + const override = { enabled: false, command: ["/opt/agent"], env: { LOGIN: "yes" } }; + const parsed = PersistedConfigSchema.parse({ + agents: { providers: { "plugin-agent": override } }, }); - - expect(result.success).toBe(false); + expect(parsed.agents?.providers?.["plugin-agent"]).toEqual(override); }); + test.each(["plugin.agent", "plugin_agent"])( + "keeps %s outside the config provider ID alphabet", + (id) => { + expect( + PersistedConfigSchema.safeParse({ agents: { providers: { [id]: { enabled: false } } } }) + .success, + ).toBe(false); + }, + ); + test("new provider without label → error", () => { const result = PersistedConfigSchema.safeParse({ agents: { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 40c4150907..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -306,6 +307,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginRegistries: PluginRegistriesSchema.optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/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 { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/legacy-entry"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "legacy-entry", [field]: "lib/entry.js" }), + ); + await mkdir(path.join(dependency, "lib")); + await writeFile( + path.join(dependency, "lib/entry.js"), + 'exports.label = "legacy entry contribution";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { label } from "legacy-entry"; export const clientLabel = label; export const serverLabel = label;', + ); + + const { clientBundle, serverBundle } = await compilePlugin(entries); + + expect(clientBundle).toContain("legacy entry contribution"); + expect(serverBundle).toContain("legacy entry contribution"); + }, + ); + it("uses the automatic JSX runtime without a React import", async () => { const entries = await createSplitPlugin(); const { clientBundle } = await compilePlugin(entries); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 80dbb1b7b0..15df395d06 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -391,6 +391,10 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom jsx: "automatic", platform: target === "server" ? "node" : "neutral", target: target === "server" ? "node20" : "es2020", + // The neutral platform reads no package.json entry fields, so packages without + // `exports` would not resolve. Read the same fields as esbuild's browser platform + // minus `browser`, since the bundle also runs in React Native. + mainFields: target === "client" ? ["module", "main"] : undefined, // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, // so apply the same compatibility transform before the app evaluates them from source. supported: target === "client" ? { "async-await": false } : undefined, diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 4b76a4af25..544b609df5 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -1,5 +1,15 @@ import { randomUUID } from "node:crypto"; -import { rename, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { + cp, + rename, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -7,6 +17,7 @@ import pino from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { DaemonConfigStore } from "../daemon-config-store.js"; import { PluginService } from "./index.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; import { ManagedPluginSources } from "./managed-source.js"; import { startNpmRegistry, @@ -168,6 +179,78 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { } describe("PluginService", () => { + it.each([ + { shadow: false, runtime: "subprocess" }, + { shadow: true, runtime: "subprocess" }, + { shadow: false, runtime: "builtin" }, + { shadow: true, runtime: "builtin" }, + ])( + "publishes status and launch metadata through $runtime despite a configured provider (extends: $shadow)", + async ({ shadow, runtime }) => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-status", + `export default function contribute(server) { + server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent", "serve"], + async status({ launch }) { return launch.env.LOGIN === "yes" ? { available: true } : { available: false, diagnostic: "Sign in" }; }, + async getCatalogCacheKey({ launch }) { return launch.command + ":" + launch.env.LOGIN; }, + async connect({ launch }) { + if (launch.command !== process.execPath || launch.env.LOGIN !== "no") throw new Error("Unexpected launch"); + return { version: 1, capabilities: [], async send() {}, onEvent() { return () => {}; }, async close() {} }; + }, + }); + return () => {}; + }`, + ); + const store = createStore(home); + await store.patch({ + providers: { + "plugin-agent": shadow + ? { extends: "acp", label: "Own provider", command: ["own-agent"] } + : { command: ["configured-agent"], enabled: false }, + }, + }); + const builtinRoot = path.join(home, "builtins"); + if (runtime === "builtin") { + await mkdir(builtinRoot); + await cp(directory, path.join(builtinRoot, "provider-status"), { recursive: true }); + } + const builtinPlugins = new BuiltinPluginLoader( + builtinRoot, + runtime === "builtin" ? ["provider-status"] : [], + ); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }), + ); + await service.start(); + try { + if (runtime === "subprocess") await service.installDirectory({ path: directory }); + const [registration] = service.getProviderRegistrations(); + expect(registration).toMatchObject({ + id: "plugin-agent", + command: ["agent", "serve"], + status: expect.any(Function), + }); + const launch = { command: process.execPath, args: [], env: { LOGIN: "no" } }; + expect(await registration!.getCatalogCacheKey!({ scope: "global", launch })).toBe( + process.execPath + ":no", + ); + expect(await registration!.status!({ launch })).toEqual({ + available: false, + diagnostic: "Sign in", + }); + expect( + await registration!.status!({ launch: { ...launch, env: { LOGIN: "yes" } } }), + ).toEqual({ available: true }); + const connection = await registration!.connect({ versions: [1], capabilities: [], launch }); + await connection.close(); + } finally { + await service.stopAllPlugins(); + } + }, + ); + it("resolves a provider icon path to sanitized inline SVG", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index f11b7c50a4..7f74c847df 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -26,14 +26,18 @@ import { PluginRuntime } from "./runtime.js"; import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { readPluginProviderIcon } from "./provider-icon.js"; -import { UsageSourceRegistry } from "./usage-sources/index.js"; +import { + type AgentUsageLookup, + type ListUsageReportsOptions, + UsageSourceRegistry, +} from "./usage-sources/index.js"; import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; -import type { UsageReference } from "../agent/agent-sdk-types.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = 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; @@ -41,11 +45,11 @@ interface PluginRuntimePort { clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[]; - identifyUsage: PluginRuntime["identifyUsage"]; fetchUsage: PluginRuntime["fetchUsage"]; discoverUsage: PluginRuntime["discoverUsage"]; connectProvider: PluginRuntime["connectProvider"]; getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; + getProviderStatus?: PluginRuntime["getProviderStatus"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; startBuiltinPlugin?(plugin: BuiltinPlugin): Promise; @@ -56,6 +60,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + usageAgents?: AgentUsageLookup; settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; @@ -80,7 +85,7 @@ export class PluginService { private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); private readonly providers = new Map(); - private readonly usageSources = new UsageSourceRegistry(); + private readonly usageSources: UsageSourceRegistry; private readonly usageSourceIdsByPlugin = new Map(); private readonly providerIdsByPlugin = new Map(); private readonly providerListeners = new Set<() => void>(); @@ -96,6 +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, + 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,18 +159,10 @@ export class PluginService { return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); } - listUsageReports(options?: { - forceRefresh?: boolean; - reportIds?: string[]; - references?: UsageReference[]; - }) { + listUsageReports(options?: ListUsageReportsOptions) { return this.usageSources.listReports(options); } - resolveUsageReference(reference: UsageReference) { - return this.usageSources.resolveReference(reference); - } - listLegacyUsage() { return this.usageSources.listLegacyUsage(); } @@ -576,18 +583,12 @@ export class PluginService { id: source.id, label: source.label, icon: source.icon, - discover: async () => { - if (!source.discover) return []; - 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; }, - identify: (input) => - this.runtime.identifyUsage(pluginId, source.id, input) as Promise<{ - key: string; - label?: string; - } | null>, fetch: (input) => { return this.runtime.fetchUsage(pluginId, source.id, input); }, @@ -612,16 +613,10 @@ export class PluginService { pluginDirectory: string, ): Promise { const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? []; - const configuredIds = new Set(Object.keys(this.configStore.get().providers)); for (const provider of metadata) { if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) { throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`); } - if (configuredIds.has(provider.id)) { - throw new Error( - `Plugin ${pluginId} cannot register configured provider ID "${provider.id}"`, - ); - } if (this.providers.has(provider.id)) { throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`); } @@ -632,6 +627,14 @@ export class PluginService { id: provider.id, label: provider.label, description: provider.description, + command: provider.command, + status: provider.hasStatus + ? (request) => { + if (!this.runtime.getProviderStatus) + throw new Error("Plugin runtime cannot resolve provider status"); + return this.runtime.getProviderStatus(pluginId, provider.id, request); + } + : undefined, getCatalogCacheKey: provider.hasCatalogCacheKey ? (options) => { if (!this.runtime.getProviderCatalogCacheKey) 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..7723721eb4 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -48,7 +48,7 @@ describe("managed Git plugin sources", () => { await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); }); - it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => { + it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); roots.push(home); @@ -61,11 +61,7 @@ describe("managed Git plugin sources", () => { Object.assign(process.env, overlay); try { const sources = new ManagedPluginSources(home); - for (const source of [ - "github:fixture/repository", - "fixture/repository", - "git:fixture/repository", - ]) { + for (const source of ["github:fixture/repository", "git:fixture/repository"]) { const candidate = await sources.prepareInstall({ source }); expect(candidate.record).toMatchObject({ kind: "git", @@ -275,3 +271,172 @@ describe("managed Git plugin sources", () => { } }, 30_000); }); + +describe("registry plugin sources", () => { + it("installs a bare id from a private registry and polls its pin without install intent", async () => { + const repository = await createRepository(); + await mkdir(path.join(repository, "packages/example"), { recursive: true }); + await rename( + path.join(repository, "paseo-plugin.json"), + path.join(repository, "packages/example/paseo-plugin.json"), + ); + await rename( + path.join(repository, "index.server.ts"), + path.join(repository, "packages/example/index.server.ts"), + ); + await commitAll(repository, "move plugin into monorepo"); + const revision = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const commit = revision.stdout.trim(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-home-")); + roots.push(home); + const requests: Array<{ + path: string | undefined; + intent: string | string[] | undefined; + authorization: string | undefined; + }> = []; + const server = createServer((req, res) => { + requests.push({ + path: req.url, + intent: req.headers["x-paseo-install"], + authorization: req.headers.authorization, + }); + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit, + pluginPath: "packages/example", + }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const host = `127.0.0.1:${address.port}`; + const url = `http://${host}/internal`; + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: url, + registries: { [host]: { authorization: "Bearer test" } }, + }); + let candidate = await sources.prepareInstall({ source: "acme/example" }); + candidate = await sources.place("managed-example", candidate); + expect( + JSON.parse(await readFile(path.join(candidate.directory, "paseo-plugin.json"), "utf8")).id, + ).toBe("managed-example"); + await sources.verifyCandidate("managed-example", candidate); + sources.commit("managed-example", candidate.record); + const preview = await sources.preview("managed-example", candidate.directory); + expect(preview.outcome).toBe("current"); + expect(preview.current?.identity).toEqual({ + kind: "git", + remote: pathToFileURL(repository).href, + pluginPath: "packages/example", + registry: { url, id: "acme/example" }, + }); + expect(requests).toEqual([ + { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + ]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + }); +}); + +it("installs and updates only the npm artifacts pinned by the plugin registry", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { resolveNpm } = await import("./managed-source/npm.js"); + const npm = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = npm.userconfig; + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-")); + roots.push(home); + const intents: Array = []; + let pin = await resolveNpm("paseo-fixture-plugin", "1.0.0", home); + const server = createServer((request, response) => { + intents.push(request.headers["x-paseo-install"]); + response.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Example", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; + try { + const sources = new ManagedPluginSources(home, options); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect((await sources.preview("example", candidate.directory)).outcome).toBe("current"); + pin = await resolveNpm("paseo-fixture-plugin", "1.1.0", home); + const restarted = new ManagedPluginSources(home, options); + const preview = await restarted.preview("example", candidate.directory); + expect(preview.target).toEqual({ kind: "npm", ...pin }); + expect(preview.outcome).toBe("update"); + const updated = await restarted.place( + "example", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + await restarted.verifyCandidate("example", updated); + expect(updated.record.registry).toEqual({ url: options.defaultUrl, id: "acme/example" }); + expect(intents).toEqual(["1", undefined, undefined, undefined]); + pin = { ...pin, integrity: "sha512-YWJj" }; + await expect(sources.prepareInstall({ source: "acme/example" })).rejects.toThrow( + "changed since review", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await npm.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index a2ab4a90f8..366d8d53b0 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -1,3 +1,8 @@ +import { + PluginRegistryIdentitySchema, + parsePluginRegistryReference, +} from "@getpaseo/protocol/plugin-registry"; +import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, realpath, rename, rm } from "node:fs/promises"; @@ -23,8 +28,12 @@ const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; // COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata. const ManagedPluginRecordSchema = z.union([ - z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }), - z.object({ kind: z.literal("npm") }), + z.object({ + kind: z.literal("git").default("git"), + remote: z.string().min(1), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ kind: z.literal("npm"), registry: PluginRegistryIdentitySchema.optional() }), ]); export type ManagedPluginRecord = z.infer; export interface ManagedPluginCandidate { @@ -33,6 +42,7 @@ export interface ManagedPluginCandidate { directory: string; record: ManagedPluginRecord; versionRoot: string; + target?: PluginUpdateTarget; } interface InstallInput { source: string; @@ -49,7 +59,10 @@ export class ManagedPluginSources { private readonly root: string; private readonly metadataPath: string; private readonly records: Record; - constructor(paseoHome: string) { + constructor( + paseoHome: string, + private readonly registryOptions: RegistryOptions = {}, + ) { this.root = path.resolve(paseoHome, "plugins"); this.metadataPath = path.join(this.root, "sources.json"); this.records = existsSync(this.metadataPath) @@ -85,6 +98,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "checkout"), identity: { kind: "git", + ...(record.registry ? { registry: record.registry } : {}), remote: redactRemoteCredentials(record.remote), pluginPath: parts.slice(2).join("/") || ".", }, @@ -100,6 +114,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "node_modules", packageName), identity: { kind: "npm", + ...(record.registry ? { registry: record.registry } : {}), packageName, pluginPath: parts.slice(2 + packageParts).join("/") || ".", }, @@ -132,6 +147,14 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + if (registry) { + if (input.ref || input.pluginPath) + throw new Error("Registry sources use their reviewed artifact path and revision"); + const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); + const candidate = await this.prepareInstall(resolved.input, resolved.target); + return { ...candidate, record: { ...candidate.record, registry } }; + } const pluginPath = normalizePluginPath(input.pluginPath); const versionRoot = await this.createStagingRoot(); try { @@ -163,7 +186,7 @@ export class ManagedPluginSources { assertPluginPath(sourceRoot, directory); await assertRealContainment(sourceRoot, directory); const { id: defaultId, build } = await readPluginManifest(directory); - return { build, defaultId, directory, record, versionRoot }; + return { build, defaultId, directory, record, versionRoot, ...(target ? { target } : {}) }; } catch (error) { await rm(versionRoot, { recursive: true, force: true }); throw error; @@ -202,7 +225,18 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.kind === "npm") { + if (current.identity.registry) { + if (selection) throw new Error("Registry updates use only the reviewed registry pin"); + const resolved = await resolveRegistryPlugin( + current.identity.registry, + this.registryOptions, + false, + ); + assertRegistryArtifactIdentity(current.identity, resolved.artifact); + target = resolved.target; + const revision = target.kind === "npm" ? target.version : target.commit; + outcome = revision === current.currentRevision ? "current" : "update"; + } else if (current.identity.kind === "npm") { target = { kind: "npm", ...(await resolveNpm( @@ -211,10 +245,7 @@ export class ManagedPluginSources { this.root, )), }; - const comparison = compareVersions(target.version, current.currentRevision); - if (comparison === 0) outcome = "current"; - else if (comparison < 0 && !selection) outcome = "installed-newer"; - else outcome = "update"; + outcome = npmUpdateOutcome(target.version, current.currentRevision, selection); } else { const record = this.records[pluginId]; if (record?.kind !== "git") throw new Error("Git source is no longer configured"); @@ -271,6 +302,14 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; + if (identity.kind !== "directory" && identity.registry) { + const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); + assertRegistryArtifactIdentity(identity, resolved.artifact); + if (!isDeepStrictEqual(resolved.target, proposal.target)) + throw new Error("Registry pin changed since review; check again"); + const candidate = await this.prepareInstall(resolved.input, proposal.target); + return { ...candidate, record: { ...candidate.record, registry: identity.registry } }; + } if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git") return this.prepareInstall( { @@ -296,6 +335,7 @@ export class ManagedPluginSources { candidate: ManagedPluginCandidate, target?: PluginUpdateTarget, ): Promise { + target ??= candidate.target; const location = this.locate(pluginId, candidate.directory, candidate.record); const revision = await this.revision(location, candidate.directory); if (target?.kind === "git" && revision !== target.commit) @@ -395,6 +435,7 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s return []; } function normalizeGitSource(source: string): string { + const explicit = source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { @@ -405,7 +446,7 @@ function normalizeGitSource(source: string): string { source = source.slice(4); } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github) { + if (github && explicit) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -578,3 +619,29 @@ async function revParse(cwd: string, ref: string): Promise { const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd }); return stdout.trim(); } + +function assertRegistryArtifactIdentity( + identity: Exclude, + artifact: Awaited>["artifact"], +): void { + const matches = + identity.kind === "npm" && artifact.kind === "npm" + ? identity.packageName === artifact.package && identity.pluginPath === "." + : identity.kind === "git" && + artifact.kind === "git" && + identity.remote === redactRemoteCredentials(artifact.remote) && + identity.pluginPath === (artifact.pluginPath ?? "."); + if (!matches) + throw new Error("Registry artifact source changed; reinstall to approve the new source"); +} + +function npmUpdateOutcome( + version: string, + current: string, + selection: PluginUpdateSelection | undefined, +): PluginUpdatePreview["outcome"] { + const comparison = compareVersions(version, current); + if (comparison === 0) return "current"; + if (comparison < 0 && !selection) return "installed-newer"; + return "update"; +} diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts new file mode 100644 index 0000000000..d57d0aea79 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,54 @@ +import { + PublishedPluginDetailSchema, + type PluginRegistries, + type PluginRegistryIdentity, +} from "@getpaseo/protocol/plugin-registry"; +import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; + +export interface RegistryOptions { + defaultUrl?: string; + registries?: PluginRegistries; +} +export async function resolveRegistryPlugin( + identity: PluginRegistryIdentity, + options: RegistryOptions, + install: boolean, +) { + const base = new URL(identity.url); + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password) + throw new Error("Registry base must be an HTTP(S) URL without credentials"); + const authorization = options.registries?.[base.host]?.authorization; + if ( + authorization && + base.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + ) + throw new Error("Registry credentials require HTTPS"); + const headers: Record = {}; + if (authorization) headers.Authorization = authorization; + if (install) headers["X-Paseo-Install"] = "1"; + const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) + throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); + const plugin = PublishedPluginDetailSchema.parse(await response.json()); + if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); + const artifact = plugin.artifact; + const target: PluginUpdateTarget = + artifact.kind === "npm" + ? { + kind: "npm", + version: artifact.version, + resolved: artifact.resolved, + integrity: artifact.integrity, + } + : { kind: "git", commit: artifact.commit }; + const input = + artifact.kind === "npm" + ? { source: `npm:${artifact.package}@${artifact.version}` } + : { source: `git:${artifact.remote}`, ref: artifact.commit, pluginPath: artifact.pluginPath }; + return { input, target, artifact }; +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 16ee512c37..e959780389 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,14 +1,22 @@ +import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, ProviderCatalogOptions, ProviderEvent, ProviderInput, + ProviderStatusRequest, +} from "@getpaseo/plugin/server/provider"; +import { + ProviderEventSchema, + ProviderInputSchema, + ProviderLaunchSchema, } from "@getpaseo/plugin/server/provider"; -import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/server/provider"; import { z } from "zod"; export interface PluginProviderMetadata { hasCatalogCacheKey?: boolean; + hasStatus?: boolean; + command?: readonly [string, ...string[]]; id: string; label: string; description?: string; @@ -19,10 +27,15 @@ export interface PluginUsageSourceMetadata { id: string; label: string; icon?: string; - discover: boolean; } export type PluginProcessRequest = + | { + type: "provider.status"; + requestId: string; + providerId: string; + request: ProviderStatusRequest; + } | { type: "initialize"; pluginId: string; @@ -39,9 +52,8 @@ export type PluginProcessRequest = } | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } - | { type: "usage.identify"; requestId: string; sourceId: string; input: unknown } | { 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"; @@ -101,6 +113,8 @@ const providerMetadataSchema = z description: z.string().optional(), iconPath: z.string().optional(), hasCatalogCacheKey: z.boolean().optional(), + hasStatus: z.boolean().optional(), + command: z.tuple([z.string().min(1)], z.string()).optional(), }) .strict(); const usageSourceMetadataSchema = z @@ -108,11 +122,11 @@ const usageSourceMetadataSchema = z id: z.string().min(1), label: z.string().min(1), icon: z.string().optional(), - discover: z.boolean(), }) .strict(); const providerConnectRequestSchema = z .object({ + launch: ProviderLaunchSchema.optional(), versions: z.array(z.number().int().positive()), capabilities: z.array(z.string()), }) @@ -125,6 +139,14 @@ const frameFields = { export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( "type", [ + z + .object({ + type: z.literal("provider.status"), + requestId: z.string().min(1), + providerId: z.string().min(1), + request: z.object({ launch: ProviderLaunchSchema.optional() }).strict(), + }) + .strict(), z .object({ type: z.literal("initialize"), @@ -141,12 +163,19 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis requestId: z.string().min(1), providerId: z.string().min(1), options: z.discriminatedUnion("scope", [ - z.object({ scope: z.literal("global"), force: z.boolean().optional() }).strict(), + z + .object({ + scope: z.literal("global"), + force: z.boolean().optional(), + launch: ProviderLaunchSchema.optional(), + }) + .strict(), z .object({ scope: z.literal("workspace"), cwd: z.string(), force: z.boolean().optional(), + launch: ProviderLaunchSchema.optional(), }) .strict(), ]), @@ -162,14 +191,6 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis }) .strict(), z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), - z - .object({ - type: z.literal("usage.identify"), - requestId: z.string(), - sourceId: z.string(), - input: z.unknown(), - }) - .strict(), z .object({ type: z.literal("usage.fetch"), @@ -179,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 1ed1bd6fc3..3f11926d7d 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -14,6 +14,7 @@ import { ProviderEventSchema, type ProviderConnection, type ProviderRegistration, + ProviderStatusSchema, } from "@getpaseo/plugin/server/provider"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -123,6 +124,14 @@ export function createPluginWorker(options: { ) { throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); } + if (provider.status !== undefined && typeof provider.status !== "function") + throw new Error(`Invalid status callback for plugin provider ${id}`); + if ( + provider.command !== undefined && + (provider.command.length === 0 || + provider.command.some((part) => typeof part !== "string" || !part.trim())) + ) + throw new Error(`Invalid command for plugin provider ${id}`); if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); providers.set(id, { ...provider, id }); } @@ -133,6 +142,7 @@ export function createPluginWorker(options: { !/^[a-z][a-z0-9._-]*$/.test(id) || !source.label.trim() || typeof source.fetch !== "function" || + typeof source.discover !== "function" || !source.input || typeof source.input.parseAsync !== "function" ) { @@ -149,6 +159,8 @@ export function createPluginWorker(options: { description: provider.description, iconPath: provider.icon, hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, + hasStatus: provider.status !== undefined, + command: provider.command, }; } @@ -289,7 +301,6 @@ export function createPluginWorker(options: { icon: source.icon ? await readPluginProviderIcon(message.pluginDirectory, source.icon) : undefined, - discover: !!source.discover, })), ); send({ @@ -328,22 +339,15 @@ export function createPluginWorker(options: { } function handleUsageRequest( - message: Extract< - PluginProcessRequest, - { type: "usage.identify" | "usage.fetch" | "usage.discover" } - >, + message: Extract, ): void { 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(source.discover ? await source.discover() : []); + return jsonTransportValue(await source.discover(message.scope)); const input = await source.input.parseAsync(message.input); - return jsonTransportValue( - message.type === "usage.identify" - ? await source.identify(input) - : await source.fetch(input), - ); + return jsonTransportValue(await source.fetch(input)); })().then( (output) => send({ type: "result", requestId: message.requestId, output }), (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), @@ -352,8 +356,8 @@ export function createPluginWorker(options: { function rejectWhileStopping(message: PluginProcessRequest): void { if ( + message.type === "provider.status" || message.type === "provider.catalog_key" || - message.type === "usage.identify" || message.type === "usage.fetch" || message.type === "usage.discover" ) { @@ -417,6 +421,18 @@ export function createPluginWorker(options: { rejectWhileStopping(message); return; } + if (message.type === "provider.status") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider || !provider.status) + throw new Error(`Provider has no status capability: ${message.providerId}`); + const output = ProviderStatusSchema.parse(await provider.status(message.request)); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } if (message.type === "provider.catalog_key") { void (async () => { const provider = providers.get(message.providerId); @@ -430,11 +446,7 @@ export function createPluginWorker(options: { ); return; } - if ( - message.type === "usage.identify" || - message.type === "usage.fetch" || - message.type === "usage.discover" - ) { + if (message.type === "usage.fetch" || message.type === "usage.discover") { handleUsageRequest(message); return; } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index c09ef9ebe2..8dd80ce499 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2030,3 +2030,28 @@ export default function contribute(plugin: any) { } }); }); + +it("rejects a usage source without discovery at registration", async () => { + const directory = await createPlugin( + "missing-discovery", + ` +export default function contribute(server) { + server.registerUsageSource({ + id: "missing-discovery", + label: "Missing discovery", + input: { parseAsync: async (value) => value }, + fetch: async () => ({ status: "available", windows: [] }), + }); + return () => {}; +} +`, + ); + const runtime = createTestRuntime(); + try { + await expect(runtime.startPlugin("missing-discovery", directory)).rejects.toThrow( + "Invalid usage source: missing-discovery", + ); + } finally { + await runtime.stopAll(); + } +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 312d4d069a..9886a368e7 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,9 @@ +import type { UsageScope } from "@getpaseo/plugin/server/usage"; +import { + ProviderStatusSchema, + type ProviderStatus, + type ProviderStatusRequest, +} from "@getpaseo/plugin/server/provider"; import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; @@ -290,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; @@ -390,27 +397,38 @@ export class PluginRuntime { return this.plugins.get(pluginId)?.usageSources ?? []; } - identifyUsage(pluginId: string, sourceId: string, input: unknown): Promise { - const loaded = this.plugins.get(pluginId); - if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); - return this.request(loaded, { - type: "usage.identify", - requestId: randomUUID(), - sourceId, - input, - }); - } - fetchUsage(pluginId: string, sourceId: string, input: unknown): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); 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( + pluginId: string, + providerId: string, + request: ProviderStatusRequest, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return ProviderStatusSchema.parse( + await this.request(loaded, { + type: "provider.status", + requestId: randomUUID(), + providerId, + request, + }), + ); } async connectProvider( @@ -504,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/session-usage-reference.e2e.test.ts b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts deleted file mode 100644 index c4f2287bbf..0000000000 --- a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { expect, test } from "vitest"; -import { ACPAgentSession } from "../agent/providers/acp-agent.js"; -import type { UsageReference } from "../agent/agent-sdk-types.js"; -import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; -import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js"; -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { DaemonClient } from "../test-utils/daemon-client.js"; -import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { BuiltinPluginLoader } from "./builtin/index.js"; - -const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); -const fixtureBuiltins = () => new BuiltinPluginLoader(fixtureRoot, ["session-usage-reference"]); - -test("resolves a provider plugin session reference through its usage source", async () => { - const directory = fileURLToPath( - new URL("./test-fixtures/session-usage-reference/", import.meta.url), - ); - const daemon = await createTestPaseoDaemon({ - pluginsEnabled: false, - builtinPlugins: fixtureBuiltins(), - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - const agent = await client.createAgent({ - provider: "fixture-session-provider", - cwd: directory, - }); - const result = await client.resolveAgentUsageReport({ agentId: agent.id }); - expect(result.reportId).toBe("fixture-session-usage:from-session"); - expect( - (await client.listUsageReports({ reportIds: [result.reportId!] })).reports[0], - ).toMatchObject({ - id: result.reportId, - sourceId: "fixture-session-usage", - report: { windows: [{ usedPct: 31 }] }, - }); - const reports = await client.listUsageReports(); - expect(reports.reports.map((entry) => entry.id)).toContain( - "fixture-session-usage:from-session", - ); - const missing = await client.createAgent({ - provider: "fixture-session-provider", - cwd: directory, - model: "missing", - }); - expect((await client.resolveAgentUsageReport({ agentId: missing.id })).reportId).toBeNull(); - } finally { - await client.close(); - await daemon.close(); - } -}, 60_000); - -test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => { - const directory = fileURLToPath( - new URL("./test-fixtures/session-usage-reference/", import.meta.url), - ); - const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const; - const logger = createTestLogger(); - const references = new Map(); - const claude = await new ClaudeAgentClient({ - logger, - resolveBinary: async () => "/test/claude/bin", - }).createSession( - { provider: "claude", cwd: directory }, - { - env: { - HOME: directory, - CLAUDE_CONFIG_DIR: "", - ANTHROPIC_BASE_URL: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_AUTH_TOKEN: "", - }, - }, - ); - references.set("claude", (await claude.getUsageReference?.()) ?? null); - await claude.close(); - const codex = new CodexAppServerAgentSession( - { provider: "codex", cwd: directory }, - null, - logger, - () => { - throw new Error("Codex runtime should not start"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { HOME: directory, OPENAI_BASE_URL: "" }, - ); - references.set("codex", await codex.getUsageReference()); - for (const provider of providers.slice(2)) { - const session = new ACPAgentSession( - { provider, cwd: directory }, - { - provider, - logger, - defaultCommand: ["unused"], - defaultModes: [], - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - }, - ); - references.set(provider, await session.getUsageReference()); - } - const agentClients = Object.fromEntries( - providers.map((provider) => { - const client = createTestAgentClient(provider); - const createSession = client.createSession.bind(client); - client.createSession = async (...args) => { - const session = await createSession(...args); - session.getUsageReference = async () => references.get(provider) ?? null; - return session; - }; - return [provider, client]; - }), - ); - const daemon = await createTestPaseoDaemon({ - pluginsEnabled: false, - builtinPlugins: fixtureBuiltins(), - agentClients, - providerOverrides: { - cursor: { extends: "acp", label: "Cursor", command: ["cursor-agent", "acp"] }, - kimi: { extends: "acp", label: "Kimi", command: ["kimi", "acp"] }, - "generic-acp": { extends: "acp", label: "Generic ACP", command: ["generic-acp", "acp"] }, - }, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - for (const provider of providers) { - const agent = await client.createAgent({ provider, cwd: directory }); - const result = await client.resolveAgentUsageReport({ agentId: agent.id }); - expect(result.reportId, provider).toBe(`${provider}:default`); - } - } finally { - await client.close(); - await daemon.close(); - } -}, 60_000); diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts deleted file mode 100644 index c5aeaede16..0000000000 --- a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { - negotiateProviderCapabilities, - type ProviderEvent, -} from "@getpaseo/plugin/server/provider"; -import { z } from "zod"; - -export default function contribute(server: PluginServerContext) { - for (const id of ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"]) { - let input = z.object({}).strict(); - if (id === "claude") input = z.object({ configDir: z.string() }).strict(); - if (id === "codex") input = z.object({ codexHome: z.string() }).strict(); - server.registerUsageSource({ - id, - label: id, - input, - identify: async () => ({ key: "default" }), - fetch: async () => ({ - status: "available", - windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], - }), - }); - } - server.registerUsageSource({ - id: "fixture-session-usage", - label: "Fixture session usage", - input: z.object({ account: z.string() }).strict(), - identify: async (input) => ({ key: (input as { account: string }).account }), - fetch: async () => ({ - status: "available", - windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], - }), - }); - server.registerProvider({ - id: "fixture-session-provider", - label: "Fixture session provider", - async connect(request) { - const capabilities = negotiateProviderCapabilities(request.capabilities, [ - "prompt.message", - "session.usage_reference", - ]); - const listeners = new Set<(event: ProviderEvent) => void>(); - const models = new Map(); - const emit = (event: ProviderEvent) => { - for (const listener of listeners) listener(event); - }; - return { - version: 1, - capabilities, - async send(input) { - if (input.type === "catalog") - emit({ - type: "catalog", - requestId: input.requestId, - catalog: { - models: [ - { id: "fixture", label: "Fixture" }, - { id: "missing", label: "Missing" }, - ], - modes: [], - }, - }); - if (input.type === "session.open") { - models.set(input.sessionId, input.config.model); - emit({ - type: "session.opened", - requestId: input.requestId, - sessionId: input.sessionId, - capabilities, - restoration: "core", - cwd: input.config.cwd, - }); - emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); - } - if (input.type === "session.usage_reference") - emit({ - type: "usage_reference", - requestId: input.requestId, - reference: { - source: - models.get(input.sessionId) === "missing" - ? "missing-source" - : "fixture-session-usage", - input: { account: "from-session" }, - }, - }); - if (input.type === "session.close") - emit({ type: "request.completed", requestId: input.requestId }); - }, - onEvent(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async close() { - listeners.clear(); - }, - }; - }, - }); - return () => {}; -} diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json deleted file mode 100644 index f7b37baee9..0000000000 --- a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json +++ /dev/null @@ -1 +0,0 @@ -{ "id": "session-usage-reference", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts index 48a4ee5eec..9a0f102a29 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -1,5 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; let fetches = 0; export default function contribute(server: PluginServerContext) { @@ -13,17 +14,32 @@ export default function contribute(server: PluginServerContext) { { account: "one" }, { account: "bad", extra: true }, { account: "throws" }, + { account: "expired" }, ]; - return inputs; + return inputs.map((input) => ({ key: String(input.account), input })); }, - identify: async (input) => ({ key: (input as { account: string }).account }), fetch: async (input) => { const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; if (account === "throws") throw new Error("fixture failure"); fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], }; }, }); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts index a8f1f28211..e42aa64edc 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -1,5 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; let fetches = 0; export default function contribute(server: PluginServerContext) { @@ -13,17 +14,32 @@ export default function contribute(server: PluginServerContext) { { account: "one" }, { account: "bad", extra: true }, { account: "throws" }, + { account: "expired" }, ]; - return inputs; + return inputs.map((input) => ({ key: String(input.account), input })); }, - identify: async (input) => ({ key: (input as { account: string }).account }), fetch: async (input) => { const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; if (account === "throws") throw new Error("fixture failure"); fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], }; }, }); 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 c414b20557..037da5e0c6 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -1,8 +1,11 @@ -import { fileURLToPath } from "node:url"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { BuiltinPluginLoader } from "./builtin/index.js"; +import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js"; const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); const subprocessDirectory = fileURLToPath( @@ -18,8 +21,13 @@ 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(); - expect(first.reports).toHaveLength(3); + 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(" entry.id === "fixture:expired"); + expect(expired?.report).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: expect.any(String), refreshedBy: "claude" }, + }); + const legacy = await client.listProviderUsage(); + expect(legacy.providers.find((provider) => provider.displayName === "Fixture")?.status).toBe( + "available", + ); + expect(legacy.providers.find((provider) => provider.status === "unavailable")?.error).toBe( + "Login expired 1h ago. Run claude to refresh it.", + ); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(subprocessDirectory, "fixture-directory"); const both = await client.listUsageReports({ forceRefresh: true }); - expect(both.reports).toHaveLength(6); + expect(both.reports).toHaveLength(8); + for (const id of ["fixture:one", "fixture-directory:one"]) { + expect(both.reports.find((entry) => entry.id === id)?.report.windows[0]).toMatchObject({ + id: "weekly", + label: "Weekly", + shortLabel: "wk", + }); + } await client.patchDaemonConfig({ pluginsEnabled: false }); - await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(3); + await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(4); } finally { await client.close(); await daemon.close(); } }, 60_000); + +test("a packaged daemon serves usage from external built-in resources", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-asar-")); + const packagePath = path.join("node_modules", "@getpaseo", "server", "dist", "server"); + const resourceRoot = path.join(root, "builtin-plugins"); + // An archive is a file, so the external compiler cannot traverse paths beneath it. + // Actual Electron archive packaging is covered by the packaged-app smoke check. + await writeFile(path.join(root, "app.asar"), "opaque archive fixture"); + const directory = path.join(resourceRoot, "listed"); + await mkdir(path.join(directory, "server"), { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "listed", requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(directory, "server", "contract.d.ts"), + "export interface Account { key: string; label: string; }", + ); + await writeFile( + path.join(directory, "server", "account.ts"), + "import type { Account } from './contract.js'; export const account: Account = { key: 'one', label: 'Test account' };", + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { z } from 'zod'; +import { account } from './server/account.js'; +export default function contribute(server) { + server.registerUsageSource({ + id: 'fixture', label: 'Fixture', input: z.object({}), + discover: async () => [{...account, input: {}}], + fetch: async () => ({ status: 'available', windows: [{ id: 'session', label: 'Session', usedPct: 37 }] }), + }); + return () => {}; +}`, + ); + const moduleUrl = pathToFileURL( + path.join(root, "app.asar", packagePath, "server", "plugins", "builtin", "index.js"), + ); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(resolveBuiltinPluginsRoot(moduleUrl), ["listed"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + const { reports } = await client.listUsageReports(); + expect( + reports.map(({ id, sourceId, sourceLabel, account, report }) => ({ + id, + sourceId, + sourceLabel, + account, + report, + })), + ).toEqual([ + { + id: "fixture:one", + sourceId: "fixture", + sourceLabel: "Fixture", + account: { label: "Test account" }, + report: { + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 37 }], + }, + }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("built-in sources deduplicate cross-harness accounts and fall back without session wiring", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "usage-cross-harness-")); + const home = path.join(root, "home"); + const plugins = path.join(root, "plugins"); + const codexHome = path.join(home, ".codex"); + const env = { CODEX_HOME: codexHome }; + async function json(file: string, value: unknown) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value)); + } + async function codexToken(token: string) { + await json(path.join(codexHome, "auth.json"), { + tokens: { access_token: token, account_id: "chatgpt-one" }, + }); + } + await codexToken("fixture-codex"); + const openCodePath = path.join(home, ".local", "share", "opencode", "auth.json"); + await json(openCodePath, { + openai: { type: "oauth", access: "fixture-opencode", accountId: "chatgpt-one" }, + }); + const piPath = path.join(home, ".pi", "agent", "auth.json"); + await json(piPath, { + "openai-codex": { type: "oauth", access: "fixture-pi-codex", accountId: "chatgpt-one" }, + anthropic: { type: "oauth", access: "fixture-pi-claude" }, + }); + await json(path.join(home, ".claude", ".credentials.json"), { + claudeAiOauth: { accessToken: "fixture-claude" }, + }); + const dbPath = path.join(home, ".omp", "agent", "agent.db"); + await mkdir(path.dirname(dbPath), { recursive: true }); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(dbPath); + db.exec( + "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)", + ); + const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, 'oauth', ?, NULL)"); + insert.run( + 1, + "openai-codex", + JSON.stringify({ + access: "fixture-omp-codex", + accountId: "chatgpt-one", + expires: Date.now() + 60_000, + }), + ); + insert.run( + 2, + "anthropic", + JSON.stringify({ access: "fixture-omp-claude", expires: Date.now() + 60_000 }), + ); + db.close(); + for (const source of ["codex", "claude"]) { + const directory = path.join(plugins, `${source}-usage-source`); + await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, { + recursive: true, + }); + // Inject home/env/HTTP at the source boundary; compile and register the real readers. + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { inputSchema } from './shared/input.js'; +import { discover, fetchUsage } from './server/usage.js'; +const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' }; +async function fetchApi(url, init) { + const token = new Headers(init.headers).get('Authorization'); + if (String(url).endsWith('/profile')) { + const second = token === 'Bearer fixture-pi-claude'; + return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } })); + } + if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 }); + const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55; + return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } })); +} +export default function contribute(server) { + server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema, + discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}), + fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup), + }); + return () => {}; +} +`, + ); + } + const daemon = await createTestPaseoDaemon({ + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + const first = (await client.listUsageReports()).reports; + expect(first.map((entry) => entry.id).sort()).toEqual([ + "claude:claude-one.org-one", + "claude:claude-two.org-two", + "codex:chatgpt-one", + ]); + expect(first.find((entry) => entry.sourceId === "codex")?.report.windows[0]?.usedPct).toBe(11); + expect( + first.find((entry) => entry.id === "claude:claude-two.org-two")?.report.windows[0]?.usedPct, + ).toBe(77); + expect(JSON.stringify(first)).not.toContain("fixture-"); + await json(path.join(home, ".claude", ".credentials.json"), { + claudeAiOauth: { accessToken: "fixture-revoked" }, + }); + const claudeFallback = ( + await client.listUsageReports({ + reportIds: ["claude:claude-one.org-one"], + forceRefresh: true, + }) + ).reports; + expect(claudeFallback[0]?.report.windows[0]?.usedPct).toBe(66); + await codexToken("fixture-revoked"); + const refresh = async () => + (await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true })) + .reports[0]; + expect((await refresh())?.report.windows[0]?.usedPct).toBe(22); + await rm(openCodePath); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(33); + await json(piPath, { + "openai-codex": { + type: "oauth", + access: "fixture-pi-codex", + accountId: "chatgpt-one", + expires: 1, + }, + anthropic: { type: "oauth", access: "fixture-pi-claude" }, + }); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(44); + await codexToken("fixture-codex"); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(11); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("a killed discovery subprocess logs kimi once and produces no provider card", async () => { + const { readFile } = await import("node:fs/promises"); + const { default: pino } = await import("pino"); + const root = await mkdtemp(path.join(os.tmpdir(), "usage-crash-")); + const log = path.join(root, "daemon.log"); + const destination = pino.destination({ dest: log, sync: true }); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: true, + logger: pino({ level: "warn" }, destination), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await writeFile( + path.join(root, "paseo-plugin.json"), + JSON.stringify({ id: "crashing-usage", requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(root, "index.server.ts"), + ` +import {z} from "zod"; +export default function contribute(server) { + server.registerUsageSource({id: "kimi", label: "Kimi", input: z.object({}), + discover: async () => {process.exit(1);}, + fetch: async () => {throw new Error("discovery must never produce an account");}, + }); + return () => {}; +}`, + ); + await client.connect(); + await client.installDirectoryPlugin(root, "crashing-usage"); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + const warnings = (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((entry) => entry.msg === "Usage source discovery failed"); + expect(warnings.map(({ level, sourceId }) => ({ level, sourceId }))).toEqual([ + { level: 40, sourceId: "kimi" }, + ]); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + destination.end(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); 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 2b8d0b672b..338ebd1d93 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -3,50 +3,37 @@ import { UsageSourceRegistry } from "./index.js"; function source(input: { id: string; - discover?: () => Promise; - identify?: (value: unknown) => Promise<{ key: string } | null>; + discover?: () => Promise>; fetch?: (value: unknown) => Promise; }) { return { id: input.id, label: input.id, discover: input.discover ?? (async () => []), - identify: - input.identify ?? - (async (value: unknown) => ({ key: (value as { account: string }).account })), fetch: input.fetch ?? (async () => ({ status: "available", windows: [] })), }; } -test("identifies accounts without fetching and preserves IDs across token rotation", async () => { +test("discovery preserves account IDs and updates input after token rotation", async () => { const registry = new UsageSourceRegistry(); - let fetches = 0; let token = "old"; registry.register( source({ id: "codex", - discover: async () => [{ account: "work", token }], - fetch: async (input) => { - fetches++; - return { - status: "available", - windows: [{ id: "token", label: (input as { token: string }).token }], - }; - }, + discover: async () => [{ key: "work", input: { token } }], + fetch: async (input) => ({ + status: "available", + windows: [{ id: "token", label: (input as { token: string }).token }], + }), }), ); - expect( - await registry.resolveReference({ source: "codex", input: { account: "work", token } }), - ).toBe("codex:work"); - expect(fetches).toBe(0); + const first = await registry.listReports(); + expect(first.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(first[0]?.report.windows[0]?.label).toBe("old"); token = "new"; - expect( - await registry.resolveReference({ source: "codex", input: { account: "work", token } }), - ).toBe("codex:work"); - expect( - (await registry.listReports({ reportIds: ["codex:work"] }))[0]?.report.windows[0]?.label, - ).toBe("new"); - expect(fetches).toBe(1); + const refreshed = await registry.listReports({ forceRefresh: true }); + expect(refreshed.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(refreshed[0]?.report.windows[0]?.label).toBe("new"); }); test("coalesces per account, caches errors, and refreshes only requested IDs", async () => { @@ -56,7 +43,7 @@ test("coalesces per account, caches errors, and refreshes only requested IDs", a registry.register( source({ id: "source", - discover: async () => [{ account: "a" }, { account: "b" }], + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), fetch: async (input) => { const account = (input as { account: string }).account; counts.set(account, (counts.get(account) ?? 0) + 1); @@ -85,88 +72,21 @@ test("coalesces per account, caches errors, and refreshes only requested IDs", a expect(counts.get("a")).toBe(3); }); -test("invalid keys become source errors and missing identities produce no report", async () => { - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "source", - discover: async () => [{ account: "bad:key" }, { account: "none" }], - identify: async (input) => { - const account = (input as { account: string }).account; - return account === "none" ? null : { key: account }; - }, - }), - ); - const reports = await registry.listReports(); - expect(reports.map((entry) => entry.id)).toEqual(["source:!error"]); - expect(reports[0]?.report.status).toBe("error"); - expect( - await registry.resolveReference({ source: "source", input: { account: "none" } }), - ).toBeNull(); -}); - test("legacy listing uses oldest fetchedAt", async () => { let now = 1000; const registry = new UsageSourceRegistry(() => now); registry.register( - source({ id: "source", discover: async () => [{ account: "a" }, { account: "b" }] }), + source({ + id: "source", + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), + }), ); - await registry.listReports({ references: [{ source: "source", input: { account: "a" } }] }); + await registry.listReports(); now = 2000; await registry.listReports({ reportIds: ["source:b"], forceRefresh: true }); expect((await registry.listLegacyUsage()).fetchedAt).toBe(new Date(1000).toISOString()); }); -test("two Codex homes and a token route resolve to their vendor account IDs", async () => { - const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { identify } = await import("../../../../../../plugins/codex-usage-source/server/usage.js"); - const personal = await mkdtemp(join(tmpdir(), "usage-personal-")); - const work = await mkdtemp(join(tmpdir(), "usage-work-")); - try { - const token = (accountId: string, email: string) => - `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, email })).toString("base64url")}.signature`; - await writeFile( - join(personal, "auth.json"), - JSON.stringify({ - tokens: { - account_id: "personal-id", - access_token: token("personal-id", "personal@example.test"), - }, - }), - ); - await writeFile( - join(work, "auth.json"), - JSON.stringify({ - tokens: { account_id: "work-id", access_token: token("work-id", "work@example.test") }, - }), - ); - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "codex", - discover: async () => [{ codexHome: personal }, { codexHome: work }], - identify: async (input) => identify(input as Parameters[0]), - }), - ); - - expect( - await registry.resolveReference({ - source: "codex", - input: { accessToken: token("work-id", "work@example.test") }, - }), - ).toBe("codex:work-id"); - expect((await registry.listReports()).map((entry) => [entry.id, entry.account.label])).toEqual([ - ["codex:personal-id", "personal@example.test"], - ["codex:work-id", "work@example.test"], - ]); - } finally { - await rm(personal, { recursive: true, force: true }); - await rm(work, { recursive: true, force: true }); - } -}); - test("concurrent requests for the same ID share one vendor fetch", async () => { let finish!: (report: unknown) => void; const response = new Promise((resolve) => { @@ -177,65 +97,279 @@ test("concurrent requests for the same ID share one vendor fetch", async () => { registry.register( source({ id: "coalesced", + discover: async () => [{ key: "one", input: {} }], fetch: async () => { fetches++; return response; }, }), ); - await registry.resolveReference({ source: "coalesced", input: { account: "one" } }); - const first = registry.listReports({ reportIds: ["coalesced:one"] }); - const second = registry.listReports({ reportIds: ["coalesced:one"], forceRefresh: true }); + const first = registry.listReports(); + const second = registry.listReports(); finish({ status: "available", windows: [] }); const [one, two] = await Promise.all([first, second]); expect(one[0]).toBe(two[0]); expect(fetches).toBe(1); }); -test("source failure IDs cannot collide with an account named error", async () => { - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "source", - discover: async () => [{ account: "error" }, { account: "bad:key" }], - }), - ); - const reports = await registry.listReports(); - expect(reports).toHaveLength(2); - expect(reports.find((entry) => entry.id === "source:error")?.report.status).toBe("available"); - expect(reports.find((entry) => entry.report.status === "error")?.id).toMatch( - /^source:[^A-Za-z0-9._-]/, - ); -}); - -test("expired cached entries are pruned when a new report is written", async () => { +test("rediscovery removes an old account from targeted refreshes", async () => { let now = 0; const registry = new UsageSourceRegistry(() => now, 100); - registry.register(source({ id: "source" })); - await registry.resolveReference({ source: "source", input: { account: "old" } }); + let account = "old"; + registry.register(source({ id: "source", discover: async () => [{ key: account, input: {} }] })); + await registry.listReports(); await registry.listReports({ reportIds: ["source:old"] }); now = 101; - await registry.resolveReference({ source: "source", input: { account: "new" } }); + account = "new"; + await registry.listReports(); await registry.listReports({ reportIds: ["source:new"] }); - const cache = Reflect.get(registry, "cache") as Map; - expect([...cache.keys()]).toEqual(["source:new"]); + expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]); }); -test("discovery failures have an ID outside the account namespace", 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[]) => { + warnings.push(args); + }, + }); + const err = new Error("subprocess stopped"); + registry.register( + source({ + id: "kimi", + discover: async () => { + throw err; + }, + }), + ); + 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"]]); +}); + +test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => { + const registry = new UsageSourceRegistry(() => 1000); + registry.register( + source({ + id: "claude", + discover: async () => + ["work", "personal", "default"].map((key) => ({ + key, + label: key === "default" ? undefined : key, + input: {}, + })), + }), + ); + expect( + (await registry.listLegacyUsage()).providers.map((provider) => provider.displayName), + ).toEqual(["claude (work)", "claude (personal)", "claude"]); +}); + +test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => { + for (const failure of ["unavailable", "error", "throw"]) { + const registry = new UsageSourceRegistry(); + const calls: number[] = []; + registry.register( + source({ + id: "codex", + discover: async () => [0, 1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => { + calls.push(Number(input)); + if (input === 0) { + if (failure === "throw") throw new Error("revoked"); + return failure === "unavailable" + ? { status: failure, problem: { kind: "rejected", status: 401 } } + : { status: failure, error: "revoked" }; + } + return { + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 42 }], + }; + }, + }), + ); + const reports = await registry.listReports(); + expect(reports).toHaveLength(1); + expect(reports[0]?.report.status).toBe("available"); + expect(calls).toEqual([0, 1]); + } +}); + +test("targeted refresh re-reads the login without discovering again", async () => { + let discoveries = 0; + let value = "old"; const registry = new UsageSourceRegistry(); registry.register( source({ id: "source", discover: async () => { - throw new Error("discovery failed"); + discoveries++; + return [{ key: "original", input: {} }]; + }, + fetch: async () => ({ status: "available", windows: [{ id: "session", label: value }] }), + }), + ); + await registry.listReports(); + value = "new"; + const reports = await registry.listReports({ + reportIds: ["source:original"], + forceRefresh: true, + }); + expect(reports[0]?.report).toEqual({ + status: "available", + windows: [{ id: "session", label: "new" }], + }); + expect(discoveries).toBe(1); +}); + +test("a thrown fetch is an error card", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "one", input: {} }], + fetch: async () => { + throw new Error("Store deleted"); }, }), ); - expect(await registry.resolveReference({ source: "source", input: { account: "error" } })).toBe( - "source:error", + expect((await registry.listReports())[0]?.report).toEqual({ + status: "error", + error: "Store deleted", + }); +}); + +test("an unavailable login falls back to the next available login", async () => { + const calls: unknown[] = []; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => { + calls.push(input); + return input === 1 + ? { + status: "unavailable", + problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z" }, + } + : { status: "available", windows: [] }; + }, + }), ); - const discovered = await registry.listReports(); - expect(discovered.map((entry) => entry.id)).toEqual(["source:!error"]); - const both = await registry.listReports({ reportIds: ["source:error", "source:!error"] }); - expect(both.map((entry) => entry.report.status)).toEqual(["available", "error"]); + expect( + (await registry.listReports()).map((entry) => ({ id: entry.id, report: entry.report })), + ).toEqual([{ id: "source:same", report: { status: "available", windows: [] } }]); + expect(calls).toEqual([1, 2]); +}); + +test("failed fallbacks preserve the final problem", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => ({ + status: "unavailable", + problem: { kind: "rejected", status: input === 1 ? 401 : 403 }, + }), + }), + ); + expect((await registry.listReports())[0]?.report).toEqual({ + status: "unavailable", + 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 a6705114de..157bf7df4e 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,35 +1,63 @@ +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 { UsageReportSchema, type ProviderUsage, type UsageReportEntry, + type UsageProblem, + type UsageReport, } from "@getpaseo/protocol/messages"; -import type { UsageReference } from "../../agent/agent-sdk-types.js"; export interface UsageSource { id: string; label: string; icon?: string; - discover(): Promise; - identify(input: unknown): Promise<{ key: string; label?: string } | null>; + discover(scope: UsageScope): Promise; fetch(input: unknown): Promise; } interface KnownReport { source: UsageSource; - input: unknown; + inputs: [unknown, ...unknown[]]; label?: string; } -/** Owns account identity, the latest input for each report, and the five-minute fetch cache. */ +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>(); constructor( 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 { @@ -39,69 +67,128 @@ 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 resolveReference(reference: UsageReference): Promise { - const source = this.sources.get(reference.source); - if (!source) return null; - return this.identify(source, reference.input); + 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); + if (!known) return []; + return [ + this.fetchId(id, known, options.forceRefresh).then((entry) => { + options.onReport?.(entry); + return entry; + }), + ]; + }), + ); } - private async identify(source: UsageSource, input: unknown): Promise { - try { - const account = await source.identify(input); - if (account === null) return null; - if ( - typeof account !== "object" || - (account.label !== undefined && typeof account.label !== "string") - ) - throw new Error(`Invalid account identity from ${source.id}`); - if (typeof account.key !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(account.key)) - throw new Error(`Invalid account key from ${source.id}`); - const id = `${source.id}:${account.key}`; - this.known.set(id, { source, input, label: account.label }); - return id; - } catch (error) { - const id = `${source.id}:!error`; - this.writeCache(id, this.errorEntry(source, id, error)); - return id; + 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] }); + } } } - async listReports( - options: { forceRefresh?: boolean; reportIds?: string[]; references?: UsageReference[] } = {}, - ): Promise { - const ids = options.reportIds ?? (await this.discoverReportIds(options.references ?? [])); - return Promise.all( - [...new Set(ids)] - .filter((id) => this.known.has(id) || this.cache.has(id)) - .map((id) => this.fetchId(id, options.forceRefresh)), - ); - } - - private async discoverReportIds(references: UsageReference[]): Promise { + private async discover(scope: UsageScope): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { + const reports = new Map(); try { - const inputs = await source.discover(); - if (!Array.isArray(inputs)) throw new Error("Usage discovery must return an array"); - return (await Promise.all(inputs.map((input) => this.identify(source, input)))).filter( - (id): id is string => id !== null, - ); + const accounts = z + .array( + z.object({ + key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), + label: z.string().optional(), + input: z.json(), + }), + ) + .parse(await source.discover(scope)); + for (const account of accounts) { + const id = `${source.id}:${account.key}`; + const known = reports.get(id); + if (known) known.inputs.push(account.input); + else reports.set(id, { source, inputs: [account.input], label: account.label }); + } } catch (error) { - const id = `${source.id}:!error`; - this.writeCache(id, this.errorEntry(source, id, error)); - return [id]; + this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); } + return reports; }), ); - const live = await Promise.all(references.map((reference) => this.resolveReference(reference))); - return [...discovered.flat(), ...live.filter((id): id is string => id !== null)]; + 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.9.3, remove after 2027-03-26. + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { const reports = await this.listReports(); return { @@ -113,41 +200,35 @@ export class UsageSourceRegistry { : new Date(this.now()).toISOString(), providers: reports.map((entry) => ({ providerId: entry.sourceId, - displayName: entry.sourceLabel, + displayName: entry.account.label + ? `${entry.sourceLabel} (${entry.account.label})` + : entry.sourceLabel, status: entry.report.status, - planLabel: entry.report.planLabel ?? null, - windows: entry.report.windows, - balances: entry.report.balances ?? [], - details: entry.report.details ?? [], - error: entry.report.error ?? null, + planLabel: entry.report.status === "available" ? (entry.report.planLabel ?? null) : null, + windows: entry.report.status === "available" ? entry.report.windows : [], + balances: entry.report.status === "available" ? (entry.report.balances ?? []) : [], + details: entry.report.status === "available" ? (entry.report.details ?? []) : [], + error: legacyError(entry.report, this.now()), })), }; } - private fetchId(id: string, forceRefresh = false): Promise { - const known = this.known.get(id); + private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { const cached = this.cache.get(id); - if (!known) return Promise.resolve(cached!.entry); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); const pending = this.pending.get(id); if (pending) return pending; const request = (async () => { - let entry: UsageReportEntry; - try { - const report = UsageReportSchema.parse(await known.source.fetch(known.input)); - entry = { - id, - sourceId: known.source.id, - sourceLabel: known.source.label, - icon: known.source.icon, - account: { label: known.label }, - fetchedAt: new Date(this.now()).toISOString(), - report, - }; - } catch (error) { - entry = this.errorEntry(known.source, id, error, known.label); - } + const entry: UsageReportEntry = { + id, + sourceId: known.source.id, + sourceLabel: known.source.label, + icon: known.source.icon, + account: { label: known.label }, + fetchedAt: new Date(this.now()).toISOString(), + report: await this.fetchBeforeDeadline(known), + }; this.writeCache(id, entry); return entry; })(); @@ -166,25 +247,58 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private errorEntry( - source: UsageSource, - id: string, - error: unknown, - label?: string, - fetchedAt = new Date(this.now()).toISOString(), - ): UsageReportEntry { - return { - id, - sourceId: source.id, - sourceLabel: source.label, - icon: source.icon, - account: { label }, - fetchedAt, - report: { - status: "error", - windows: [], - error: error instanceof Error ? error.message : String(error), - }, - }; + 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)) { + if (report.status === "available") break; + report = await this.fetchLogin(known.source, input); + } + return report; + } + + private async fetchLogin(source: UsageSource, input: unknown): Promise { + try { + return UsageReportSchema.parse(await source.fetch(input)); + } catch (error) { + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } } } + +// The legacy RPC has only an error string; the current app owns its own presentation. +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyProblem(problem: UsageProblem, now: number): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const elapsed = Math.max(0, now - Date.parse(problem.expiresAt)); + let ago = "just now"; + if (elapsed >= 86_400_000) ago = `${Math.floor(elapsed / 86_400_000)}d ago`; + else if (elapsed >= 3_600_000) ago = `${Math.floor(elapsed / 3_600_000)}h ago`; + else if (elapsed >= 60_000) ago = `${Math.floor(elapsed / 60_000)}m ago`; + return `Login expired ${ago}. ${remedy}`; +} + +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyError(report: UsageReport, now: number): string | null { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return legacyProblem(report.problem, now); + return null; +} diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index b966a6b8aa..73403f5908 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -1,7 +1,10 @@ import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js"; import type { WorktreeSource } from "../utils/worktree.js"; -export type WorktreeCreationIntent = Exclude; +export type WorktreeCreationIntent = Exclude< + WorktreeSource, + { kind: "restore" | "restore-from-base" } +>; export interface ResolveWorktreeCreationIntentInput { worktreeSlug?: string; diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 612bb7e9a9..14e0221913 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -21,8 +21,6 @@ import type { AgentStreamEvent, } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; -import { validateProviderOptions } from "../agent/provider-options.js"; -import { ClaudeProviderOptionsSchema } from "../agent/providers/claude/options.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; @@ -66,12 +64,6 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick - validateProviderOptions("claude", ClaudeProviderOptionsSchema, options), - applyOptions: (config: AgentSessionConfig, options: AgentSessionConfig["providerOptions"]) => ({ - ...config, - ...(options ? { providerOptions: options } : {}), - }), }; let workspaceArchiveInProgress = false; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f83040c8c9..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, @@ -134,7 +135,6 @@ import { type AgentPermissionResponse, type AgentRunOptions, type AgentSessionConfig, - type UsageReference, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; @@ -451,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; @@ -514,9 +515,7 @@ export interface SessionOptions { listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[]; - references?: UsageReference[]; }): Promise; - resolveUsageReference(reference: UsageReference): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; @@ -813,6 +812,7 @@ export class Session { getTransportBufferedAmount, onLifecycleIntent, onWorkspaceRecovered, + publishScriptStatusUpdate, logger, downloadTokenStore, pushNotifications, @@ -999,8 +999,6 @@ export class Session { }); this.usageSession = new UsageSession({ emit: (msg) => this.emit(msg), - listAgents: () => this.agentManager.listAgents(), - getAgent: (agentId) => this.agentManager.getAgent(agentId), runtime: pluginRuntime, logger: this.sessionLogger, }); @@ -1153,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, @@ -3016,8 +3017,6 @@ export class Session { return this.usageSession.handleLegacyList(msg); case "usage.list_reports.request": return this.usageSession.handleListReports(msg); - case "agent.resolve_usage_report.request": - return this.usageSession.handleResolveAgentReport(msg); default: return undefined; } @@ -5217,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(); @@ -5244,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)) @@ -5258,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)) @@ -5265,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 { @@ -7254,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, @@ -7301,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, @@ -7577,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; } @@ -7590,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 7914af3872..2aa65992b8 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -1,18 +1,11 @@ import pino from "pino"; import { expect, test } from "vitest"; -import type { AgentSession } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { UsageSession } from "./usage-session.js"; -test("collects live references for usage reports and resolves an agent report", async () => { +test("lists reports from usage sources", async () => { const emitted: SessionOutboundMessage[] = []; - const references: unknown[] = []; - const reference = { source: "fixture", input: { account: "one" } }; - const agent = { - session: { - getUsageReference: async () => reference, - } as AgentSession, - }; + const requested: Array<{ forceRefresh?: boolean; reportIds?: string[] }> = []; const entry = { id: "fixture:one", account: {}, @@ -23,17 +16,12 @@ test("collects live references for usage reports and resolves an agent report", }; const usage = new UsageSession({ emit: (message) => emitted.push(message), - listAgents: () => [agent, { session: null }], - getAgent: (id) => (id === "one" ? agent : null), runtime: { async listUsageReports(options) { - references.push(options.references); + requested.push({ forceRefresh: options.forceRefresh, reportIds: options.reportIds }); + options.onReport?.(entry); return [entry]; }, - async resolveUsageReference(value) { - references.push(value); - return entry.id; - }, async listLegacyUsage() { return { fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }; }, @@ -42,15 +30,10 @@ test("collects live references for usage reports and resolves an agent report", }); await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); - await usage.handleResolveAgentReport({ - type: "agent.resolve_usage_report.request", - requestId: "one", - agentId: "one", - }); - expect(references).toEqual([[reference], reference]); - expect(emitted.map((message) => message.type)).toEqual([ - "usage.list_reports.response", - "agent.resolve_usage_report.response", + expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]); + expect(emitted).toEqual([ + { type: "usage.list_reports.update", payload: { requestId: "list", report: entry } }, + { type: "usage.list_reports.response", payload: { requestId: "list", error: null } }, ]); }); @@ -58,15 +41,10 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ emit: (message) => emitted.push(message), - listAgents: () => [], - getAgent: () => null, runtime: { async listUsageReports() { return []; }, - async resolveUsageReference() { - return null; - }, async listLegacyUsage(): Promise { throw new Error("quota service down"); }, @@ -80,23 +58,17 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = }); }); -test("unknown agent returns agent_not_found", async () => { +test("request failures terminate with an error response and no updates", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ emit: (message) => emitted.push(message), - listAgents: () => [], - getAgent: () => null, - runtime: { - listUsageReports: async () => [], - resolveUsageReference: async () => null, - listLegacyUsage: async () => ({ fetchedAt: "", providers: [] }), - }, logger: pino({ level: "silent" }), }); - await usage.handleResolveAgentReport({ - type: "agent.resolve_usage_report.request", - requestId: "missing", - agentId: "missing", - }); - expect(emitted[0]).toMatchObject({ type: "rpc_error", payload: { code: "agent_not_found" } }); + 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 6665a7454f..500c03a088 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -1,23 +1,12 @@ +import type { ListUsageReportsOptions } from "../../plugins/usage-sources/index.js"; import type pino from "pino"; import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; -import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; -interface UsageAgent { - session: AgentSession | null; -} - export interface UsageSessionOptions { emit(message: SessionOutboundMessage): void; - listAgents(): UsageAgent[]; - getAgent(agentId: string): UsageAgent | null; runtime?: { - listUsageReports(options: { - forceRefresh?: boolean; - reportIds?: string[]; - references: UsageReference[]; - }): Promise; - resolveUsageReference(reference: UsageReference): Promise; + listUsageReports(options: ListUsageReportsOptions): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; logger: pino.Logger; @@ -31,52 +20,28 @@ export class UsageSession { ): Promise { try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const references = - msg.reportIds === undefined - ? ( - await Promise.all( - this.options - .listAgents() - .map( - async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null, - ), - ) - ).filter((reference): reference is UsageReference => reference !== null) - : []; - const reports = await this.options.runtime.listUsageReports({ + await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, reportIds: msg.reportIds, - references, + 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"); - } - } - - async handleResolveAgentReport( - msg: Extract, - ): Promise { - try { - if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const agent = this.options.getAgent(msg.agentId); - if (!agent) { - this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found"); - return; - } - const reference = (await agent.session?.getUsageReference?.()) ?? null; - const reportId = reference - ? await this.options.runtime.resolveUsageReference(reference) - : null; this.options.emit({ - type: "agent.resolve_usage_report.response", - payload: { requestId: msg.requestId, reportId }, + type: "usage.list_reports.response", + payload: { + requestId: msg.requestId, + error: error instanceof Error ? error.message : String(error), + }, }); - } catch (error) { - this.emitError(msg, error, "agent_resolve_usage_report_failed"); } } @@ -109,23 +74,4 @@ export class UsageSession { }); } } - - private emitError( - msg: Extract< - SessionInboundMessage, - { type: "usage.list_reports.request" | "agent.resolve_usage_report.request" } - >, - 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-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 82ffafa4d7..964f305eb8 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -357,6 +357,67 @@ test("preserves ordinary checkout behavior when no separate base was recorded", ).toMatchObject({ currentBranch: "feature", baseRef: "feature" }); }); +test("restores a branchless workspace from its recorded base so work can continue", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/release"); + execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + writeFileSync(join(fixture.repoDir, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" }); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "restore", + branch: null, + }); + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + + const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }); + expect(checkout.currentBranch).toBeTruthy(); + expect(checkout.currentBranch).not.toBe("feature"); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "release"], { + cwd: fixture.repoDir, + encoding: "utf8", + }).trim(), + ); +}); + +test("restores a branchless workspace from the repository default when its base is missing", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base"); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(), + ); +}); + test("restores the branch HEAD with a base name when the exact base is missing", async () => { const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main"); const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index f40e0e8afc..8b0e9a853a 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,5 +1,7 @@ +import { randomUUID } from "node:crypto"; import { basename } from "node:path"; +import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; import { @@ -7,6 +9,7 @@ import { isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, rollbackCreatedPaseoWorktree, + type WorktreeSource, } from "../../../utils/worktree.js"; import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js"; import { @@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: { message: "The archived workspace directory no longer exists and cannot be recreated.", }; } - if (!workspace.branch) { - return { - kind: "unavailable", - workspaceId, - reason: "worktree_branch_missing", - message: "The archived worktree has no branch recorded, so it cannot be restored.", - }; - } - // COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110 // lack placement ownership; remove the project-root fallback after 2027-01-17. const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath; @@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: { workspace: PersistedWorkspaceRecord, sourceRepoRoot: string, ): Promise { - const branch = workspace.branch; - if (!branch) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Workspace ${workspace.workspaceId} has no branch to restore`, - }); - } + const source: WorktreeSource = workspace.branch + ? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch } + : { + kind: "restore-from-base", + branchName: `restored/${randomUUID().slice(0, 8)}`, + baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch), + }; try { await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 }); @@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: { const result = await createWorktree({ cwd: sourceRepoRoot, worktreeSlug: basename(previousWorktreePath), - source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch }, + source, runSetup: false, paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, @@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: { return { inspect, restore }; } +async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise { + if (recordedBase) { + try { + await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot }); + return recordedBase; + } catch { + // The saved base was removed; use the repository default for continued work. + } + } + const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot); + if (!defaultBranch) { + throw new WorktreeRequestError({ + code: "unknown", + message: + "The saved base is unavailable and the repository has no default branch to restore from.", + }); + } + return defaultBranch; +} + function createRecoveryPlan( input: | { action: "unarchive"; workspace: PersistedWorkspaceRecord } 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 30a9e59aa9..4dcdc680ab 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -5,7 +5,7 @@ import { MessageReceipts } from "./message-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; -import { hostname as getHostname } from "node:os"; +import { getHostName } from "./host-name.js"; import { randomUUID } from "node:crypto"; import { monitorEventLoopDelay } from "node:perf_hooks"; import type { AgentManager, AgentMetricsSnapshot } from "./agent/agent-manager.js"; @@ -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, @@ -1772,7 +1780,7 @@ export class VoiceAssistantWebSocketServer { status: "server_info", protocolVersion: WS_PROTOCOL_VERSION, serverId: this.serverId, - hostname: getHostname(), + hostname: getHostName(), version: this.daemonVersion, permissions: session.getPermissions(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. 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/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4769bed7d0..479d4cfeab 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -20,6 +20,7 @@ import { writeFileSync, } from "fs"; import { join } from "path"; +import { createServer, type AddressInfo, type Socket } from "net"; import { tmpdir } from "os"; import { createRealpathAwarePathMatcher } from "./path"; @@ -52,6 +53,21 @@ function createLegacyWorktreeForTest( }); } +// A remote that accepts connections and never answers, like a VPN-only host while off the VPN. +async function startSilentRemote(): Promise<{ url: string; close: () => Promise }> { + const connections = new Set(); + const server = createServer((socket) => connections.add(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/repo.git`, + close: () => { + for (const socket of connections) socket.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; @@ -263,6 +279,103 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + + describe("branch-off from a remote-tracking base", () => { + function git(args: string[], cwd: string): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + }).trim(); + } + + // The repo caches /main at "initial" while the remote itself moves one commit ahead, + // as when nothing has fetched since someone else pushed. + function pushPastCachedRemoteRef(remoteName: string): string { + const remoteDir = join(tempDir, "remote.git"); + git(["clone", "--bare", "-q", repoDir, remoteDir], tempDir); + git(["remote", "add", remoteName, remoteDir], repoDir); + git(["fetch", "-q", remoteName], repoDir); + + const pusherDir = join(tempDir, "pusher"); + git(["clone", "-q", remoteDir, pusherDir], tempDir); + git(["config", "user.email", "test@test.com"], pusherDir); + git(["config", "user.name", "Test"], pusherDir); + writeFileSync(join(pusherDir, "file.txt"), "pushed after the last fetch\n"); + git(["commit", "-q", "-am", "remote ahead"], pusherDir); + git(["push", "-q", "origin", "HEAD:main"], pusherDir); + return git(["rev-parse", "HEAD"], pusherDir); + } + + it("starts the new branch at the remote tip, not the cached origin/main", async () => { + const remoteTip = pushPastCachedRemoteRef("origin"); + const fetchRefspecs = git(["config", "--get-all", "remote.origin.fetch"], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-remote-tip", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-remote-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + expect(git(["config", "--get-all", "remote.origin.fetch"], repoDir)).toBe(fetchRefspecs); + }); + + it("refreshes a base on a remote other than origin", async () => { + const remoteTip = pushPastCachedRemoteRef("team/upstream"); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-upstream-tip", + cwd: repoDir, + baseBranch: "refs/remotes/team/upstream/main", + worktreeSlug: "from-upstream-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + }); + + it("branches from the cached ref when the remote is unreachable", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + git(["remote", "set-url", "origin", join(tempDir, "missing.git")], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-cached-ref", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-cached-ref", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + }); + + it("branches from the cached ref when the remote accepts but never answers", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + const silentRemote = await startSilentRemote(); + git(["remote", "set-url", "origin", silentRemote.url], repoDir); + + try { + const startedAt = Date.now(); + const created = await createLegacyWorktreeForTest({ + branchName: "from-silent-remote", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-silent-remote", + paseoHome, + }); + + // Clients give up on a create request after 60s, so the fallback has to land well before. + expect(Date.now() - startedAt).toBeLessThan(30_000); + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + } finally { + await silentRemote.close(); + } + }, 45_000); + }); }); describe("slugify", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ad5ba0f8b3..f7f28205e0 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -186,6 +186,7 @@ export type WorktreeSource = | { kind: "branch-off"; baseBranch: string; branchName: string } | { kind: "checkout-branch"; branchName: string } | { kind: "restore"; branchName: string; baseRef: string | null } + | { kind: "restore-from-base"; baseRef: string; branchName: string } | { kind: "checkout-change-request"; forge: string; @@ -1341,6 +1342,21 @@ async function resolveRestoredWorktreeSourcePlan( }; } +async function resolveRestoredWorktreeFromBasePlan( + cwd: string, + source: Extract, +): Promise { + await validateGitBranchName(cwd, source.branchName); + const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef); + const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); + return { + branchName, + metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef), + metadataBaseRef: baseRef, + addArguments: ["-b", branchName, "--no-track", baseRef], + }; +} + async function resolveBranchOffWorktreeSourcePlan( cwd: string, source: Extract, @@ -1352,6 +1368,7 @@ async function resolveBranchOffWorktreeSourcePlan( const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); const branchExists = await localBranchExists(cwd, branchName); const base = branchExists ? branchName : resolvedBaseBranch; + await refreshRemoteTrackingBaseRef(cwd, base); const candidateBranch = branchExists ? desiredSlug : branchName; const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch); @@ -1377,6 +1394,8 @@ async function resolveWorktreeSourcePlan({ return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug); case "restore": return resolveRestoredWorktreeSourcePlan(cwd, source); + case "restore-from-base": + return resolveRestoredWorktreeFromBasePlan(cwd, source); case "checkout-branch": { await validateGitBranchName(cwd, source.branchName); await ensureLocalBranch(cwd, source.branchName); @@ -1684,6 +1703,41 @@ async function resolveBaseBranchForWorktree( throw new Error(`Base branch not found: ${normalized}`); } +// Clients give up on a create request after 60s. A remote that accepts the connection and never +// answers (a VPN-only host while off the VPN) must fall back to the cached ref well before that. +const REMOTE_BASE_REFRESH_TIMEOUT_MS = 15_000; + +// A remote-tracking base is only as fresh as the last fetch, and the background fetch only runs +// while a workspace of the repository is observed. Refresh the one base branch so the new branch +// starts at the remote tip; when the fetch fails (offline, auth) the cached ref is still usable. +async function refreshRemoteTrackingBaseRef(cwd: string, baseRef: string): Promise { + if (!baseRef.startsWith("refs/remotes/")) { + return; + } + try { + // Remote names may contain slashes, so the owning remote is looked up rather than parsed. + const { stdout } = await runGitCommand(["remote"], { cwd }); + const remoteName = stdout + .split("\n") + .map((name) => name.trim()) + .find((name) => name.length > 0 && baseRef.startsWith(`refs/remotes/${name}/`)); + if (!remoteName) { + return; + } + const remoteBranch = baseRef.slice(`refs/remotes/${remoteName}/`.length); + // No refspec is added to remote..fetch: one left behind breaks later fetches once the + // branch is deleted on the remote. + await runGitCommand(["fetch", remoteName, `+refs/heads/${remoteBranch}:${baseRef}`], { + cwd, + envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + timeout: REMOTE_BASE_REFRESH_TIMEOUT_MS, + acceptExitCodes: [0, 1, 128], + }); + } catch { + // The fetch timed out; branch from the cached ref. + } +} + async function ensureLocalBranch(cwd: string, branchName: string): Promise { if (await localBranchExists(cwd, branchName)) { return; diff --git a/packages/website/e2e/download-thanks.spec.ts b/packages/website/e2e/download-thanks.spec.ts new file mode 100644 index 0000000000..bc33f8f087 --- /dev/null +++ b/packages/website/e2e/download-thanks.spec.ts @@ -0,0 +1,47 @@ +import { expect, test, type Page } from "playwright/test"; + +const APPLE_SILICON_DMG = /\/getpaseo\/paseo\/releases\/download\/v[^/]+\/Paseo-[^/]+-arm64\.dmg$/; + +async function openDownloadPage(page: Page) { + // Serve a stand-in file so the test never downloads a real release. + await page.route(APPLE_SILICON_DMG, (route) => + route.fulfill({ + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=Paseo.dmg", + }, + body: "dmg", + }), + ); + await page.goto("/download"); +} + +async function clickAppleSilicon(page: Page) { + await page.getByRole("link", { name: "Apple Silicon", exact: true }).click(); +} + +test("a download button opens the thanks page and requests the file", async ({ page }) => { + await openDownloadPage(page); + + const fileRequest = page.waitForRequest(APPLE_SILICON_DMG); + await clickAppleSilicon(page); + + await fileRequest; + await expect(page).toHaveURL(/\/download\/thanks\?file=/); + await expect(page.getByRole("heading", { name: "Thanks for downloading Paseo" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + APPLE_SILICON_DMG, + ); +}); + +test("the thanks page starts a browser download", async ({ page, browserName }) => { + test.skip(browserName === "webkit", "WebKit reports no download event for a stubbed response"); + await openDownloadPage(page); + + const download = page.waitForEvent("download"); + await clickAppleSilicon(page); + + expect((await download).url()).toMatch(APPLE_SILICON_DMG); +}); diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..e3b2eeb115 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,239 @@ +import { expect, test, type Page } from "playwright/test"; +import { CATEGORIES } from "../src/plugins/categories"; + +async function openPlugins(page: Page) { + // Wait for hydration so typing reaches React rather than the server-rendered input. + await page.goto("/plugins", { waitUntil: "networkidle" }); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +} + +test("browses from the directory into a category, a plugin, and its author", async ({ + page, + context, +}) => { + // WebKit has no clipboard permission to grant; record what the page writes instead. + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + (window as unknown as { __copied?: string }).__copied = text; + return Promise.resolve(); + }, + }, + }); + }); + await openPlugins(page); + + const themes = page.getByRole("region", { name: "Themes" }); + await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible(); + + await browseCategory(page, "Git"); + await expect(page).toHaveURL(/category=/); + await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1); + await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0); + + await openPlugin(page, /Fresh Worktrees/); + await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); + await expect( + page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }), + ).toBeVisible(); + await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + ).toBeVisible(); + await copyInstallCommand(page); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) + .toBe("paseo plugin install omercnet/fresh-worktrees"); + + const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumbs).toContainText("Plugins"); + await expect(breadcrumbs).toContainText("Git"); + await expect(breadcrumbs).toContainText("Fresh Worktrees"); + + await openAuthor(page, "Omer Cohen"); + await expect(page).toHaveURL(/\/plugins\/omercnet$/); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); +}); + +test("filters by search and clears to the full directory", async ({ page }) => { + await openPlugins(page); + + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/q=graphite/); + await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await searchPlugins(page, "zzzz-nothing"); + await expect(page.getByText("No plugins match.")).toBeVisible(); + await page.getByRole("link", { name: "Clear filters" }).click(); + await expect(page).toHaveURL(/\/plugins\/?$/); + await expect(page.getByRole("region", { name: "Themes" })).toBeVisible(); +}); + +test("explains a plugin that is not listed", async ({ page }) => { + await page.goto("/plugins/acme/does-not-exist"); + await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); + await page.getByRole("link", { name: "Browse all plugins" }).click(); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); + +async function copyInstallCommand(page: Page) { + await page.getByRole("button", { name: "Copy to clipboard" }).first().click(); +} + +async function browseCategory(page: Page, category: string) { + await page.getByRole("link", { name: category, exact: true }).click(); +} +async function openPlugin(page: Page, name: RegExp) { + await page.getByRole("link", { name }).first().click(); +} +async function openAuthor(page: Page, name: string) { + await page.getByRole("link", { name }).first().click(); +} +async function searchPlugins(page: Page, query: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).fill(query); +} + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + + await page.goto("/plugins/omercnet"); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Omer Cohen – Paseo plugins", "/plugins/omercnet"); + + const response = await page.goto("/plugins/omercnet/fresh-worktrees"); + expect(response?.status()).toBe(200); + expect(response?.headers()["cache-control"]).toBe("private, no-store"); + expect(response?.headers()["x-robots-tag"]).toBeUndefined(); + await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + ).toBeVisible(); + await expectPageMetadata( + page, + "Fresh Worktrees – Paseo plugin", + "/plugins/omercnet/fresh-worktrees", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + + await page.goto("/plugins/tomgrin10/graphite"); + await expectPageMetadata(page, "Graphite – Paseo plugin", "/plugins/tomgrin10/graphite"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://paseo.sh/og-image.png", + ); + }); + + test("returns real 404 pages for unknown plugins and authors", async ({ page }) => { + const plugin = await page.goto("/plugins/acme/does-not-exist"); + expect(plugin?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); + await expect(page).toHaveTitle("Plugin not found – Paseo"); + const author = await page.goto("/plugins/does-not-exist"); + expect(author?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); + await expect(page).toHaveTitle("Author not found – Paseo"); + }); + + test("discovers plugin and author URLs through robots and the sitemap index", async ({ + request, + }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain("Sitemap: https://paseo.sh/sitemap-index.xml"); + const index = await request.get("/sitemap-index.xml"); + expect(await index.text()).toContain("https://paseo.sh/sitemap-plugins.xml"); + const plugins = await request.get("/sitemap-plugins.xml"); + expect(plugins.status()).toBe(200); + expect(plugins.headers()["content-type"]).toContain("application/xml"); + expect(await plugins.text()).toContain("https://paseo.sh/plugins/omercnet"); + expect(await plugins.text()).toContain( + "https://paseo.sh/plugins/omercnet/fresh-worktrees", + ); + }); +}); + +async function expectPageMetadata(page: Page, title: string, path: string) { + await expect(page).toHaveTitle(title); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "website"); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + "summary_large_image", + ); + const description = await page.locator('meta[name="description"]').getAttribute("content"); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( + "content", + description!, + ); +} + +test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + "href", + "https://paseo.cafe", + ); + await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); + const response = await page.goto("/plugins"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); + +// External deployments own their registry contents; these assertions use the local fixture. +test.describe("registry category layout", () => { + test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + + test("orders populated categories and hides the empty Extras category", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 })).toHaveText([ + "Daemon management", + "Themes", + "Providers", + "Orchestration", + "Git", + "Workspaces", + "Sidebar", + "Utils", + ]); + await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0); + for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) { + await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText( + category.description, + ); + } + }); +}); diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs new file mode 100644 index 0000000000..211fee9f38 --- /dev/null +++ b/packages/website/e2e/registry-server.mjs @@ -0,0 +1,14 @@ +import { createServer } from "node:http"; +import registry from "./registry.fixture.json" with { type: "json" }; + +// Serve the published registry contract so SSR and browser navigation use the same data. +createServer((request, response) => { + response.setHeader("Content-Type", "application/json"); + if (request.url === "/index.json") { + response.end(JSON.stringify(registry)); + return; + } + const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + response.statusCode = plugin ? 200 : 404; + response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); +}).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json new file mode 100644 index 0000000000..6b75599d3d --- /dev/null +++ b/packages/website/e2e/registry.fixture.json @@ -0,0 +1,631 @@ +{ + "schemaVersion": 1, + "registry": { + "name": "Paseo plugins", + "url": "https://plugins.paseo.sh" + }, + "generatedAt": "2026-10-04T15:55:47.882Z", + "categories": [ + { + "slug": "daemon-management", + "label": "Daemon management", + "description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers." + }, + { + "slug": "themes", + "label": "Themes", + "description": "Change the app color scheme." + }, + { + "slug": "providers", + "label": "Providers", + "description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback." + }, + { + "slug": "orchestration", + "label": "Orchestration", + "description": "Coordinate agents, delegate work, and send messages across hosts." + }, + { + "slug": "git", + "label": "Git", + "description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews." + }, + { + "slug": "workspaces", + "label": "Workspaces", + "description": "Add workspace panels and tabs for boards, browsers, documents, and terminals." + }, + { + "slug": "sidebar", + "label": "Sidebar", + "description": "Add sidebar items and panels for usage, notifications, pricing, and links." + }, + { + "slug": "extras", + "label": "Extras", + "description": "Add presence, pets, puzzles, wellbeing reminders, and demos." + }, + { + "slug": "utils", + "label": "Utils", + "description": "Extend the composer and timeline with commands, prompts, skills, and small helpers." + } + ], + "plugins": [ + { + "id": "alhassanaraouf/base2tone", + "name": "Base2Tone", + "description": "Base2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md.", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/alhassanaraouf/paseo-themes/tree/HEAD/packages/paseo-base2tone-theme" + }, + "categories": ["themes"], + "author": { + "npm": "alhassanaraouf", + "name": "alhassanaraouf", + "github": "alhassanaraouf" + }, + "screenshots": [ + "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." + }, + { + "id": "dorasto/sayr", + "name": "Sayr", + "description": "A paseo plugin for viewing and managing your Sayr tasks", + "artifact": { + "kind": "npm", + "package": "@sayrio/paseo-plugin", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sayrio/paseo-plugin/-/paseo-plugin-0.2.0.tgz", + "integrity": "sha512-LPmVkEPWl8Km6US4zQAzhqSbGm7lBDwCjgNoysOhCsDiRy0jbtDKYRg1XVgwwUkoEwhF1EdZHCPwIuT8IizyWQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/dorasto/sayr/tree/HEAD/packages/paseo-plugin", + "commit": "e70919b89df3cf9e95c18242939193a79c7593b1" + }, + "categories": ["workspaces"], + "author": { + "npm": "trent-001", + "name": "Tom Lundy", + "github": "dorasto" + }, + "screenshots": [ + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T10:39:28.935Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" + }, + { + "id": "gpambrozio/github-board", + "name": "GitHub Board", + "description": "Paseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-github-board", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-github-board/-/paseo-github-board-0.10.0.tgz", + "integrity": "sha512-G6LxSsbt4Kt36yWEB2rXUxOwNLFsA4DbF20ViroVqaicMPjaw5H4JUWXjzEZAxWJT+0DlH12/TtqN4I0sNoQtg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/github-board", + "commit": "530a66c592e20915169b5e67a35de8793f71ec60" + }, + "categories": ["git"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-01T17:46:20.500Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" + }, + { + "id": "gpambrozio/herald", + "name": "Herald", + "description": "Paseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-herald", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-herald/-/paseo-herald-0.5.1.tgz", + "integrity": "sha512-rXdlaxgRlYP3nmZrvbG29sQeluz5JC7TPuJDgKzyh9itDH1AvZYGjLgGp9XZGKxdBBhaGxgGAFhDh+VTL8h9dw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/herald", + "commit": "b658d0ab58735959e46d12660ae179c002cdadda" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-27T19:03:31.943Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" + }, + { + "id": "gpambrozio/launchd-jobs", + "name": "launchd Jobs", + "description": "Paseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-launchd-jobs", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-launchd-jobs/-/paseo-launchd-jobs-0.5.0.tgz", + "integrity": "sha512-H6CQnalMdyQjW9ta9ZT40qEPJsREgNFSEAH8EC4sJXdz2Hz9tIsInSUmm2bDcgfY3D3HFffUrwf1mmeevi/wUA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/launchd-jobs", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["daemon-management"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T17:56:41.061Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" + }, + { + "id": "gpambrozio/model-pricing", + "name": "Model Pricing", + "description": "Paseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-model-pricing", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-model-pricing/-/paseo-model-pricing-0.3.0.tgz", + "integrity": "sha512-jLfC6iCLOwNACiili8nw3/qmBymoj6YHitMeGCa2RggiMdUhT6n+INRsSjoTs2kNzeYMftMfQ3MZJXeLLF1opA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/model-pricing", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-22T18:03:14.979Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" + }, + { + "id": "gpambrozio/skills", + "name": "Skills", + "description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-skills", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-skills/-/paseo-skills-0.4.0.tgz", + "integrity": "sha512-zMgP2U/vrRsLkKhoQICkN52+YoDy2WkkYymEDmruduKxf4Dq99Ct8Pk3xqJyAzijq2oprMFYLbj/wT6XepBLeA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/skills", + "commit": "d04187ee60ca3702ca5843803567600427102d99" + }, + "categories": ["utils"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "screenshots": [], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-01T22:25:58.192Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" + }, + { + "id": "hungcuong9125/prompt-kit", + "name": "PromptKit", + "description": "PromptKit - rewrite and transform prompts directly from the Paseo composer.", + "artifact": { + "kind": "npm", + "package": "paseo-prompt-kit", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/paseo-prompt-kit/-/paseo-prompt-kit-0.6.0.tgz", + "integrity": "sha512-eiagOY1OokDx2iihfe3Vjh2dgKKP6IsBKbJYbfhM+TXw3aU7duV/rs/TUnBSeeWAk0cEsVg2Jd+EkeaPM8IJdA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/hungcuong9125/paseo-prompt-kit" + }, + "categories": ["utils"], + "author": { + "npm": "hungcuong9125", + "name": "hungcuong9125", + "github": "hungcuong9125" + }, + "screenshots": [ + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-30T02:57:56.473Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." + }, + { + "id": "omercnet/agent-monitor", + "name": "Agent Monitor", + "description": "A Paseo plugin that provides a host-wide agent triage roster.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-agent-monitor", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-agent-monitor/-/paseo-agent-monitor-1.2.0.tgz", + "integrity": "sha512-dY7/3f49sPMVwb+Wr7iUsw4jCoQWRX2UF9Li+uU8yJdN0cj4BvTsz4GDiR3OsKk8zJ1/o01DSRi1e4uDRS3TmA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/agent-monitor", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:35.639Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." + }, + { + "id": "omercnet/beads", + "name": "Beads", + "description": "A dependency-aware Beads work queue for each Paseo workspace.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-beads", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-beads/-/paseo-beads-1.2.0.tgz", + "integrity": "sha512-UkI1SNP73RUrW0ap4qOx2Jv1GCxkrw4HIMrWOFCS3FDoPg2jw06DqHC8Jf34psaFglxXKWLAOkfL2wEZJhlUFw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-beads", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:29.149Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." + }, + { + "id": "omercnet/dracula", + "name": "Dracula", + "description": "Dracula Classic and Alucard Classic themes for Paseo.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-dracula", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-dracula/-/paseo-dracula-1.2.0.tgz", + "integrity": "sha512-I3yUiFIvaIe3jdvuQ1tZyngsfgukaUJwEQza2XYkEaB8yIA0pplZehqJLvqqmA403cjNXdVtsdW7umPtmnycqw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-dracula", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["themes"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:18.185Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." + }, + { + "id": "omercnet/fresh-worktrees", + "name": "Fresh Worktrees", + "description": "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-fresh-worktrees", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-fresh-worktrees/-/paseo-fresh-worktrees-2.2.0.tgz", + "integrity": "sha512-WRarzdfTbrZ36JfzmvX9HfnIYh6Pp+GV9TkoeVwM7I6f1MAEZ4gzPEmT51lWuUyMGvwTs2HFB7LthCa+VYs7IA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/fresh-worktrees", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:06.720Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" + }, + { + "id": "omercnet/omp", + "name": "OMP", + "description": "Paseo integration for OMP, including its direct provider and workspace tooling.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-omp", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-omp/-/paseo-omp-1.2.0.tgz", + "integrity": "sha512-ueZede5D34hoNrdF4y6DBaomErA+hHcv+bcz0Sd6eArQKqF8L6ecKXdBe2tEZd3Ev8uQwxMHuiDQhMBx3q6gOw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-omp", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["providers"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:36.267Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." + }, + { + "id": "omercnet/pr-radar", + "name": "PR Radar", + "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-pr-radar", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-pr-radar/-/paseo-pr-radar-1.2.0.tgz", + "integrity": "sha512-sk+zuzTGZ5Vmri2toic3z3uChm4d6kEShSr/UjkqfOTeILrsZVDHUc+XKX/CtZIQazT/vWvbgZ115+KUiAonCw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/pr-radar", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:37:04.327Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." + }, + { + "id": "omercnet/shared-browser", + "name": "Shared Browser", + "description": "One real Chromium session per Paseo workspace, shared live across every connected client.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-shared-browser", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-shared-browser/-/paseo-shared-browser-1.2.0.tgz", + "integrity": "sha512-ddvtkBSzHLFzyTRZFixO1y+QXOKW0of8Uo3PSkETRhW5xhd9ep0jqsJJF9JSfWLt1ouvVa5K8zFXic/EBzQ8lw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-shared-browser", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:38:48.289Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." + }, + { + "id": "omercnet/tell-agent", + "name": "Tell Agent", + "description": "Send messages to agents in other workspaces on the same Paseo host.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-tell-agent", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-tell-agent/-/paseo-tell-agent-1.2.0.tgz", + "integrity": "sha512-4XqL1RK1x3TQLoRRKTkZfDxytJbUCEnu2em5pbPDpsGQ5Gv6VN1h07mBci9iW8s2FbNINohNcIBdUaAMTXdOHA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/tell-agent", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "screenshots": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-10-03T09:36:15.719Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." + }, + { + "id": "tomgrin10/defer", + "name": "Defer", + "description": "Defer messages to Paseo coding agents until a chosen time or usage-window reset.", + "artifact": { + "kind": "npm", + "package": "paseo-defer", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/paseo-defer/-/paseo-defer-2.1.9.tgz", + "integrity": "sha512-xBBf4TUC8zkTh9WMzQRRDt0alRAYPRCmUdBnBI1ZEkatUFC4i9MxFHgaTY939xij99dASM/261IWqctcKsQppQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-defer" + }, + "categories": ["utils"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "screenshots": [ + "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-27T09:59:16.553Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." + }, + { + "id": "tomgrin10/graphite", + "name": "Graphite", + "description": "Workspace-native Graphite stack status and PR action radar for Paseo.", + "artifact": { + "kind": "npm", + "package": "paseo-graphite", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/paseo-graphite/-/paseo-graphite-0.2.4.tgz", + "integrity": "sha512-aAFvh+118c5mmSQ3sTsDKO8Is7g1ZU8m2+0fisr8nFrgPMu0m2mokPJnM/LaIqQc3Zaaak1chB6M6k0dWOPeIA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-graphite" + }, + "categories": ["git"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "screenshots": [], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-23T10:36:50.112Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." + } + ] +} diff --git a/packages/website/package.json b/packages/website/package.json index de616b3507..a2159f2062 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.10.0", + "version": "0.11.0-beta.3", "private": true, "type": "module", "scripts": { @@ -14,6 +14,8 @@ "dependencies": { "@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/assets/agent-icons/agoragentic.svg b/packages/website/src/assets/agent-icons/agoragentic.svg new file mode 100644 index 0000000000..b1372e6835 --- /dev/null +++ b/packages/website/src/assets/agent-icons/agoragentic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/amp.svg b/packages/website/src/assets/agent-icons/amp.svg new file mode 100644 index 0000000000..314881aff8 --- /dev/null +++ b/packages/website/src/assets/agent-icons/amp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/auggie.svg b/packages/website/src/assets/agent-icons/auggie.svg new file mode 100644 index 0000000000..215107744a --- /dev/null +++ b/packages/website/src/assets/agent-icons/auggie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/website/src/assets/agent-icons/autohand.svg b/packages/website/src/assets/agent-icons/autohand.svg new file mode 100644 index 0000000000..f3bc983c4d --- /dev/null +++ b/packages/website/src/assets/agent-icons/autohand.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/cline.svg b/packages/website/src/assets/agent-icons/cline.svg new file mode 100644 index 0000000000..aeeafbc61e --- /dev/null +++ b/packages/website/src/assets/agent-icons/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/codebuddy.svg b/packages/website/src/assets/agent-icons/codebuddy.svg new file mode 100644 index 0000000000..d1f4b1d74f --- /dev/null +++ b/packages/website/src/assets/agent-icons/codebuddy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/copilot.svg b/packages/website/src/assets/agent-icons/copilot.svg new file mode 100644 index 0000000000..626d33badc --- /dev/null +++ b/packages/website/src/assets/agent-icons/copilot.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/cortex-code.svg b/packages/website/src/assets/agent-icons/cortex-code.svg new file mode 100644 index 0000000000..28f87a258e --- /dev/null +++ b/packages/website/src/assets/agent-icons/cortex-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/corust.svg b/packages/website/src/assets/agent-icons/corust.svg new file mode 100644 index 0000000000..9f30636cb0 --- /dev/null +++ b/packages/website/src/assets/agent-icons/corust.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/crow.svg b/packages/website/src/assets/agent-icons/crow.svg new file mode 100644 index 0000000000..1169116cd9 --- /dev/null +++ b/packages/website/src/assets/agent-icons/crow.svg @@ -0,0 +1,9 @@ + + + diff --git a/packages/website/src/assets/agent-icons/deepagents.svg b/packages/website/src/assets/agent-icons/deepagents.svg new file mode 100644 index 0000000000..abd818ec47 --- /dev/null +++ b/packages/website/src/assets/agent-icons/deepagents.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/dimcode.svg b/packages/website/src/assets/agent-icons/dimcode.svg new file mode 100644 index 0000000000..1fa31ce884 --- /dev/null +++ b/packages/website/src/assets/agent-icons/dimcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/dirac.svg b/packages/website/src/assets/agent-icons/dirac.svg new file mode 100644 index 0000000000..4fbb06ceea --- /dev/null +++ b/packages/website/src/assets/agent-icons/dirac.svg @@ -0,0 +1,6 @@ + + δ + + + + diff --git a/packages/website/src/assets/agent-icons/factory-droid.svg b/packages/website/src/assets/agent-icons/factory-droid.svg new file mode 100644 index 0000000000..5c6fb8d1ff --- /dev/null +++ b/packages/website/src/assets/agent-icons/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/assets/agent-icons/fast-agent.svg b/packages/website/src/assets/agent-icons/fast-agent.svg new file mode 100644 index 0000000000..a07fab2886 --- /dev/null +++ b/packages/website/src/assets/agent-icons/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/gemini.svg b/packages/website/src/assets/agent-icons/gemini.svg new file mode 100644 index 0000000000..588d89c52a --- /dev/null +++ b/packages/website/src/assets/agent-icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/glm.svg b/packages/website/src/assets/agent-icons/glm.svg new file mode 100644 index 0000000000..d552d2a3d0 --- /dev/null +++ b/packages/website/src/assets/agent-icons/glm.svg @@ -0,0 +1 @@ +Z.ai diff --git a/packages/website/src/assets/agent-icons/goose.svg b/packages/website/src/assets/agent-icons/goose.svg new file mode 100644 index 0000000000..c492885426 --- /dev/null +++ b/packages/website/src/assets/agent-icons/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/grok.svg b/packages/website/src/assets/agent-icons/grok.svg new file mode 100644 index 0000000000..35db840319 --- /dev/null +++ b/packages/website/src/assets/agent-icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/website/src/assets/agent-icons/junie.svg b/packages/website/src/assets/agent-icons/junie.svg new file mode 100644 index 0000000000..54ce5ff9a9 --- /dev/null +++ b/packages/website/src/assets/agent-icons/junie.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/kilo.svg b/packages/website/src/assets/agent-icons/kilo.svg new file mode 100644 index 0000000000..8af6e96f34 --- /dev/null +++ b/packages/website/src/assets/agent-icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/kimi.svg b/packages/website/src/assets/agent-icons/kimi.svg new file mode 100644 index 0000000000..4f7547cf79 --- /dev/null +++ b/packages/website/src/assets/agent-icons/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/minion-code.svg b/packages/website/src/assets/agent-icons/minion-code.svg new file mode 100644 index 0000000000..eb3d8eb31d --- /dev/null +++ b/packages/website/src/assets/agent-icons/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/mistral-vibe.svg b/packages/website/src/assets/agent-icons/mistral-vibe.svg new file mode 100644 index 0000000000..b13631b96d --- /dev/null +++ b/packages/website/src/assets/agent-icons/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/nova.svg b/packages/website/src/assets/agent-icons/nova.svg new file mode 100644 index 0000000000..5e19f58879 --- /dev/null +++ b/packages/website/src/assets/agent-icons/nova.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/poolside.svg b/packages/website/src/assets/agent-icons/poolside.svg new file mode 100644 index 0000000000..91de4c46d4 --- /dev/null +++ b/packages/website/src/assets/agent-icons/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/qoder.svg b/packages/website/src/assets/agent-icons/qoder.svg new file mode 100644 index 0000000000..417d83693d --- /dev/null +++ b/packages/website/src/assets/agent-icons/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/qwen-code.svg b/packages/website/src/assets/agent-icons/qwen-code.svg new file mode 100644 index 0000000000..6a33da557a --- /dev/null +++ b/packages/website/src/assets/agent-icons/qwen-code.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/assets/agent-icons/sigit.svg b/packages/website/src/assets/agent-icons/sigit.svg new file mode 100644 index 0000000000..334fc95cba --- /dev/null +++ b/packages/website/src/assets/agent-icons/sigit.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/stakpak.svg b/packages/website/src/assets/agent-icons/stakpak.svg new file mode 100644 index 0000000000..64425076ed --- /dev/null +++ b/packages/website/src/assets/agent-icons/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/vtcode.svg b/packages/website/src/assets/agent-icons/vtcode.svg new file mode 100644 index 0000000000..5ae616428c --- /dev/null +++ b/packages/website/src/assets/agent-icons/vtcode.svg @@ -0,0 +1,4 @@ + + + VT + diff --git a/packages/website/src/components/agent-icons.tsx b/packages/website/src/components/agent-icons.tsx index 7b6b66146d..fda21cf176 100644 --- a/packages/website/src/components/agent-icons.tsx +++ b/packages/website/src/components/agent-icons.tsx @@ -1,4 +1,4 @@ -// Agent provider brand marks, shared by the landing page and the Hub page. +// Agent provider brand marks, shared by the landing page, the Hub page, and /agents. import type * as React from "react"; @@ -82,3 +82,51 @@ export function PiIcon(props: React.SVGProps) { ); } + +export function OmpIcon(props: React.SVGProps) { + return ( + + ); +} + +export function MuseCodeIcon(props: React.SVGProps) { + return ( + + ); +} + +export function AntigravityIcon(props: React.SVGProps) { + return ( + + ); +} diff --git a/packages/website/src/components/agent-page-icon.tsx b/packages/website/src/components/agent-page-icon.tsx new file mode 100644 index 0000000000..e0f7a2601e --- /dev/null +++ b/packages/website/src/components/agent-page-icon.tsx @@ -0,0 +1,57 @@ +// The mark for any agent page. Only /agents needs every agent's mark, so the +// vendored SVG set lives here instead of in agent-icons, keeping it out of the +// homepage and agent landing page bundles. + +import { Bot } from "lucide-react"; +import type * as React from "react"; +import { + AntigravityIcon, + ClaudeCodeIcon, + CodexIcon, + CursorIcon, + MuseCodeIcon, + OmpIcon, + OpenCodeIcon, + PiIcon, +} from "~/components/agent-icons"; +const AGENT_PAGE_ICONS: Record> = { + "claude-code": ClaudeCodeIcon, + codex: CodexIcon, + opencode: OpenCodeIcon, + pi: PiIcon, + omp: OmpIcon, + cursor: CursorIcon, + "muse-code": MuseCodeIcon, + antigravity: AntigravityIcon, +}; + +// Marks vendored from the app's ACP provider catalog, named by agent page slug. +const VENDORED_AGENT_PAGE_SVGS = import.meta.glob("../assets/agent-icons/*.svg", { + eager: true, + query: "?raw", + import: "default", +}) as Record; + +const VENDORED_AGENT_PAGE_MARKUP = new Map( + Object.entries(VENDORED_AGENT_PAGE_SVGS).map(([path, svg]) => [ + path.slice(path.lastIndexOf("/") + 1, -".svg".length), + { __html: svg }, + ]), +); + +/** The mark for an agent page, falling back to a generic bot for agents without one. */ +export function AgentPageIcon({ slug, className }: { slug: string; className?: string }) { + const Icon = AGENT_PAGE_ICONS[slug]; + if (Icon) return ; + const markup = VENDORED_AGENT_PAGE_MARKUP.get(slug); + if (markup) { + return ( +