Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 7cc47701ad fix(paseo-agent): polish provider settings UI
Keep provider instance keys stable for credentials and model addressing while allowing display-name edits.
2026-07-02 15:15:37 +02:00
Mohamed Boudra 6a2b5ea5ea feat(paseo-agent): add browser OAuth start mode
Default daemon-run OAuth to browser mode while keeping device-code explicit for remote-safe setup.
2026-07-02 14:51:26 +02:00
Mohamed Boudra 269aa53c27 fix(paseo-agent): retain prompts and required tools
Emit submitted prompts into the daemon timeline so refresh and reload can replay the full Paseo Agent conversation. Keep nonpersistent providers from getting synthetic resume handles, and gate Paseo tool injection through provider capabilities.
2026-07-02 14:48:32 +02:00
Mohamed Boudra 03b45f8e11 fix(paseo-agent): make catalog auth deterministic
Catalog auth derivation used an environment probe, so missing local keys could make provider listing throw. Catalog hints are now static and CLI tests scrub provider-key env vars.
2026-07-02 13:57:12 +02:00
Mohamed Boudra 02ec891fe4 fix(cli): wait for daemon feature flags
Provider and pairing gates now wait for the daemon's server_info message instead of reading the cache immediately after connect. This removes a startup race where slower CLI cold starts could treat a supported daemon as missing the feature.
2026-07-02 13:31:48 +02:00
Mohamed Boudra 5d67032ef6 fix(paseo-agent): support empty model-provider setup
Allow catalog provider setup to persist credentials before explicit model overrides exist, while keeping unrelated provider snapshots warmable after Paseo Agent config changes.
2026-07-02 13:12:23 +02:00
Mohamed Boudra d6e4e5f3ce refactor(paseo-agent): derive catalog from Pi
Keep Paseo catalog entries as curated provider refs while deriving model metadata, endpoints, and auth defaults from Pi at runtime.
2026-07-02 12:40:21 +02:00
Mohamed Boudra 2ba7562dff fix(paseo-agent): use working OAuth catalog model
Live proof showed the previous OAuth catalog default was rejected at session start. Switch the catalog, docs, fixtures, and regenerated schema to the working default while keeping provider config generic.
2026-07-02 12:06:02 +02:00
Mohamed Boudra 363e76858f feat(app): add Paseo Agent provider catalog UI 2026-07-02 11:38:11 +02:00
Mohamed Boudra 27e713671d feat(cli): add catalog-driven provider setup 2026-07-02 11:26:56 +02:00
Mohamed Boudra 4e5737169f feat: wire paseo agent provider catalog 2026-07-02 11:07:49 +02:00
Mohamed Boudra fce3d14a83 feat(paseo-agent): add model provider catalog
Keep persisted provider config structurally open and resolve catalog semantics at runtime.
2026-07-02 10:38:17 +02:00
Mohamed Boudra 343dd2d10d Regenerate lockfile after rebase onto 0.1.103 2026-07-02 08:40:26 +02:00
Mohamed Boudra a410617d4a Open the model provider type wire schema
A closed z.enum inside the strict get_providers payload meant any provider
type added daemon-side failed the entire WS envelope parse on older clients,
silently dropping the response and timing out the settings sheet. Make
providerType an open string on the wire; the daemon's config schema keeps the
closed set and setProvider rejects unknown types with a clear error. Also
correct the paseoAgentConfig COMPAT version, the provider CLI descriptions,
and the stale no-app-UI doc claim.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 4a8a841f72 Report context window usage for Paseo Agent
toAgentUsage dropped SessionStats.contextUsage, leaving the context-window
indicator blank on every Paseo Agent session. Mirror the Pi provider's
mapping into contextWindowMaxTokens/contextWindowUsedTokens.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 7a6d2d86d7 Name the layers: agent providers and model providers
Record the naming decision in the glossary: bare "provider" splits into
agent provider (the harness: Claude Code, Codex, Paseo Agent) and model
provider (a model API attached to Paseo Agent). Bare "Agent" is the
runnable identity — an agent provider today, a named agent definition
tomorrow — resolved through one CLI namespace (--agent). Agent session
stays the running instance.

Retire "inference provider" across docs, UI strings, and identifiers
(PaseoAgentModelProvider, paseoAgentModelProviders). Labels and internal
names only — wire and config keys are untouched.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 341af162b0 Adapt Paseo Agent to rebased main
Main replaced AgentClient.listModels/listModes with a single required
fetchCatalog(); migrate PaseoAgentClient to it. Regenerate the lockfile so
the @earendil-works/pi-* deps are present (rebase conflicts took main's
lockfile at each step).
2026-07-02 08:39:06 +02:00
Mohamed Boudra 96067da1d3 Open Paseo Agent E2E provider settings 2026-07-02 08:38:58 +02:00
Mohamed Boudra 888f28990b Move OAuth file mode checks to POSIX tests 2026-07-02 08:38:58 +02:00
Mohamed Boudra b1abec4512 Resolve Paseo Agent credentials asynchronously 2026-07-02 08:38:58 +02:00
Mohamed Boudra 1a084eb8b0 Align desktop Nix npm deps fetcher 2026-07-02 08:38:58 +02:00
Mohamed Boudra 42ab7927f2 Fix PR CI follow-up failures 2026-07-02 08:38:58 +02:00
Mohamed Boudra 56d908c6dc Use Nix npm fetcher v2 2026-07-02 08:38:46 +02:00
Mohamed Boudra 2cfb29f1f0 Expose Pi TUI dependency to Nix 2026-07-02 08:38:46 +02:00
Mohamed Boudra c3827466eb Share Paseo Agent env reference helpers 2026-07-02 08:38:27 +02:00
Mohamed Boudra c52482368b Precompile Paseo Agent permission matchers 2026-07-02 08:38:27 +02:00
Mohamed Boudra 8089c802f0 Fix Paseo Agent PR review issues 2026-07-02 08:38:27 +02:00
Mohamed Boudra 3e535aa2d7 Fix Paseo Agent Zod 4 record schemas 2026-07-02 08:38:27 +02:00
Mohamed Boudra 4063671e3f Add first-class Paseo Agent definitions 2026-07-02 08:38:27 +02:00
Mohamed Boudra 9665ba07a3 Fix Paseo Agent ChatGPT auth home lookup 2026-07-02 08:38:27 +02:00
Mohamed Boudra 4086f65096 Add Paseo Agent prompt profiles 2026-07-02 08:38:27 +02:00
Mohamed Boudra 44afecc366 Add remote Paseo Agent configuration 2026-07-02 08:38:27 +02:00
Mohamed Boudra c9cd414571 Add Paseo Agent provider 2026-07-02 08:38:27 +02:00
178 changed files with 23223 additions and 9441 deletions
-21
View File
@@ -1,26 +1,5 @@
# Changelog
## 0.1.104-beta.2 - 2026-07-03
### Added
- Agents can drive the in-app browser: open tabs, navigate, click, type, and take screenshots
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
### Improved
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
### Fixed
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
## 0.1.103 - 2026-07-01
### Added
+28 -29
View File
@@ -21,35 +21,34 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): model-provider catalog, generic config, OAuth store, CLI/app setup |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
+1 -1
View File
@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site
-57
View File
@@ -1,57 +0,0 @@
# Browser Capture Harness
The desktop capture harness is the real-Electron verification path for browser screenshots.
It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest remains paintable and has a copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack without per-capture
stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured
by retrying until the frame appears;
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
Run it with the repo Electron:
```bash
npm run capture-harness --workspace=@getpaseo/desktop
```
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window
focus; a normal Electron app launch can still activate the app and steal focus.
Harness windows are then created hidden, positioned in a screen corner, skipped from
the taskbar where Electron supports it, and revealed with `showInactive()` from
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
the compositor only needs visible inactive windows, and harness runs must not steal
focus from the person using the machine.
The harness writes PNG evidence and `results.json` to:
```text
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for the production P1 attach-off parking state,
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
overlap at `left:0`, `top:0`.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
invalidates before each attempt, and retries known first-frame failures within the
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
+9 -4
View File
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
@@ -17,11 +17,13 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. 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`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
@@ -39,5 +41,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
## Inconsistencies (documented, not papered over)
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
+351
View File
@@ -0,0 +1,351 @@
# Paseo Agent provider
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
Use it like any other agent provider:
```bash
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
```
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
instance and the `openai/gpt-4o-mini` model exposed by that instance.
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
> smoking, otherwise stale `dist` may reject or omit `agents.paseo` behavior. Always pass
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
> daemon on `:6767`.
## Timeline and persistence
Paseo Agent does not write or resume Pi CLI session files such as
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
## Provider catalog
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
thinking maps, and OAuth registry data at runtime.
The current catalog contains four entries:
| id | Pi provider | default models | auth source |
| ------------- | -------------- | -------------- | --------------------------------------------- |
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
Users can store the OpenRouter credential first, but they must choose explicit model ids
before running Paseo Agent through that provider. The other catalog entries expose Pi's
bundled model list unless an instance sets `options.models`.
## Config shape
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
keys; provider types are catalog ids. Use the catalog id as the default instance name,
or create several instances of the same type with different models, keys, or endpoint
overrides.
```jsonc
{
"agents": {
"paseo": {
"defaultAgent": "orchestrator",
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
"providers": {
"openrouter-main": {
"type": "openrouter",
"options": {
"apiKey": "$OPENROUTER_API_KEY",
"models": [
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
],
},
},
"chatgpt": {
"type": "chatgpt",
},
"kimi": {
"type": "kimi",
"options": {
"apiKey": "$KIMI_API_KEY",
"models": [{ "id": "k2p7" }],
},
},
},
},
},
}
```
Most options are overrides over Pi-derived provider data:
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
mirrors Pi's config-value semantics: literals and commands count as configured; env
references count only when every referenced env var is set in the daemon environment.
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
config.
- `models[]` is an instance override. Omit it to use that entry's default policy, which
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
when a single backend serves mixed protocols or when Pi has no data for a custom id.
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
Env references make config portable: `config.json` can be copied between machines while
the secret stays in that machine's daemon environment or keychain command.
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
explicit session model wins, then the selected agent definition's model, then
`agents.paseo.defaultModel`, then Pi's first available model.
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
## Authentication
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
Redacted provider responses include an optional `auth` state:
- `Connected` means the key or credential expression resolves locally. It does not make a
network call, so a fake literal key still reports connected until a real session uses it.
- `Needs attention` means the instance exists but the auth expression does not currently
resolve, the OAuth store binding does not match, or another auth precondition is missing.
- `not configured` is used by older/no-auth responses.
The redacted provider `available` flag mirrors local credential availability. The Paseo
Agent runtime still needs at least one exposed model before it can start a session.
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
output.
### OAuth store
OAuth credentials live in Paseo's store, not in another tool's auth file:
```text
$PASEO_HOME/paseo-agent/auth.json
```
The store is created through Pi's `AuthStorage`. The parent directory is private and the
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
rotation back into the same Paseo-owned file.
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
catalog flow or configured base URL changes, the old credential is left on disk but the
provider reports `Needs attention` until it is authorized again. This prevents a token
for one OAuth target from silently being used against another.
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
The current catalog only uses `openai-codex` for `chatgpt`.
## CLI setup
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
isolated daemon.
Commands:
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
`--device-code`, `--json`, and `--host` as needed.
- `paseo provider ls` lists configured instances and redacted auth state.
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
it pointed at that instance.
Examples:
```bash
printf '%s\n' "$OPENROUTER_API_KEY" |
paseo provider add \
openrouter \
--api-key-stdin \
--model openai/gpt-4o-mini \
--host 127.0.0.1:7911
```
```bash
paseo provider add kimi \
--model k2p7 \
--host 127.0.0.1:7911
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
```
```bash
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
```
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
API-key providers it writes the configured key expression into `config.json`. For OAuth
providers it first writes the provider config, then stores the OAuth credential in the
daemon's Paseo-owned auth store.
`provider add` is idempotent for the same instance name: running it again updates the
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
provider instance and clears `defaultModel` if it pointed at the removed instance.
## App setup
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
settings, then the Paseo Agent provider section. The app fetches the catalog from the
connected daemon, shows catalog entries in the picker, and renders either an API-key
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
daemon-run starts: browser redirect for the local fast path and device code for
remote-safe authorization. Relay connections present device code first; direct TCP does
not prove same-machine by itself, so both options remain available.
Configured rows show the redacted provider state from the daemon: label, provider type,
models, availability, and auth state. The app gates this UI on
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
instead of trying to synthesize the feature through older RPCs.
## Wire surface
The catalog surface is gated by:
```text
server_info.features.paseoAgentCatalog
```
RPC names use dotted namespaces:
| request | response |
| --------------------------------------------------- | ---------------------------------------------------- |
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
complete the localhost redirect on the daemon host. Browser mode returns
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
stores the credential, and returns the redacted `auth` state. Device-code mode returns
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
provider authorizes.
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
new catalog entries, and new clients can show "update host" only when the daemon lacks the
catalog feature.
## Adding a catalog entry
Adding a new model-provider type should be a data change:
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
should not expose its full model list by default.
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
resolution simple.
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
and CLI/app rendering if the new entry exercises a new shape.
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
addressing.
## MCP tools
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
the model. On session start the provider connects to each server, lists its tools, and
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
back to the model. Connections are torn down on session close. Servers that fail to
connect or list are logged and skipped rather than failing the session.
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed: MCP
servers come from Paseo's normal injection/config, not from `agents.paseo`.
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
## Agent definitions
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
```jsonc
{
"agents": {
"paseo": {
"defaultAgent": "orchestrator",
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
"providers": {},
},
},
}
```
Example `$PASEO_HOME/agents/orchestrator.md`:
```markdown
---
name: Orchestrator
description: Coordinates work through Paseo-managed agents
prompt: extend
mcp: [paseo]
model: openrouter-main/openai/gpt-4o-mini
tools: [read, grep, paseo__list_agents, paseo__create_agent]
permissions:
- tool: paseo__archive_*
action: deny
---
!{{./partials/collaboration.md}}
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
summarize handoffs clearly.
!{{./partials/review-rules.md}}
```
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
the append list. `prompt: override` uses the agent body as the custom base prompt, so
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
appended after the agent, and the daemon-level append prompt is appended last.
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
an explicit session model wins, then the selected agent model, then
`agents.paseo.defaultModel`, then Pi's first available model.
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
Paths are relative to the file containing the directive and are confined to
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
chains, oversized definitions, and frontmatter inside partials are rejected.
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
an ordered first-match policy for active tool calls. The first matching `tool` pattern
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
MCP injection still supplies the actual server; if an agent declares an MCP server that
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
+1
View File
@@ -45,6 +45,7 @@ buildNpmPackage rec {
nodejs = nodejs_22;
inherit (paseo) npmDeps;
npmDepsFetcherVersion = 2;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
+1 -1
View File
@@ -1 +1 @@
sha256-LNETnvGaJsv86DlHSrWNabVqvi5kFfmrCJLAq5Bt3EY=
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
+1
View File
@@ -52,6 +52,7 @@ buildNpmPackage rec {
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
npmDepsFetcherVersion = 2;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+2898 -52
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.2",
"version": "0.1.103",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+130
View File
@@ -0,0 +1,130 @@
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { openSettingsHostSection } from "./settings";
type PaseoAgentDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "connect"
| "removePaseoAgentProvider"
| "setPaseoAgentProvider"
| "storePaseoAgentOAuthCredential"
>;
interface ApiKeyProviderInput {
catalogId: string;
apiKey: string;
}
interface OAuthProviderInput {
catalogId: string;
}
interface ExpectedProvider {
name: string;
providerLabel: string;
auth: "Connected" | "Needs attention";
modelCount: number;
}
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
}
export async function openPaseoAgentSettings(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
const sheet = page.getByTestId("paseo-agent-settings-sheet");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
}
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(page.getByLabel("Models")).toHaveCount(0);
await page.getByLabel("API key").fill(provider.apiKey);
await page.getByRole("button", { name: "Save provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
}
export async function startOAuthProviderSignIn(
page: Page,
provider: OAuthProviderInput,
): Promise<void> {
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Sign in with browser", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
}
export async function expectModelProviderListed(
page: Page,
expected: ExpectedProvider,
): Promise<void> {
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
await expect(
page.getByRole("listitem", {
name: new RegExp(
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
),
}),
).toBeVisible();
}
export async function seedChatGptProvider(providerName: string): Promise<void> {
const client = await connectPaseoAgentClient();
try {
await client.setPaseoAgentProvider({
name: providerName,
providerType: "chatgpt",
options: {
models: [{ id: "gpt-5.4-mini", reasoning: true }],
},
});
await client.storePaseoAgentOAuthCredential({
name: providerName,
credential: {
type: "oauth",
access: "fake-access-token",
refresh: "fake-refresh-token",
expires: 4_102_444_800,
futureField: { passthrough: true },
},
});
} finally {
await client.close().catch(() => undefined);
}
}
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
const client = await connectPaseoAgentClient();
try {
for (const name of providerNames) {
await client.removePaseoAgentProvider(name);
}
} finally {
await client.close().catch(() => undefined);
}
}
+7 -7
View File
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
// Model B entry points into the New Workspace screen. The per-project
// "+ New workspace" sidebar row is gone; the surviving entries are the global
// button (universal) and each git project's own new-worktree icon (preselects
// that project). These specs prove the global entry opens the screen, the
// project icon preselects the right project across the reused 'new' screen, and
// non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
// select it in the picker (its row has no new-worktree icon).
await openGlobalNewWorkspaceComposer(page);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,153 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import {
addApiKeyProvider,
cleanupPaseoAgentProviders,
expectModelProviderListed,
openPaseoAgentSettings,
seedChatGptProvider,
startOAuthProviderSignIn,
} from "./helpers/paseo-agent";
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
const CLOSE_PROVIDER = "phase-e-close-ui";
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
const RENAME_PROVIDER = "phase-e-rename-ui";
test.describe("Paseo Agent provider configuration", () => {
const providerNamesToCleanup = new Set<string>();
test.afterEach(async () => {
await cleanupPaseoAgentProviders(providerNamesToCleanup);
providerNamesToCleanup.clear();
});
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
await openPaseoAgentSettings(page);
await addApiKeyProvider(page, {
catalogId: "openrouter",
apiKey: "sk-or-phase-e-write-only",
});
await expectModelProviderListed(page, {
name: DEFAULT_OPENROUTER_PROVIDER,
providerLabel: "OpenRouter",
modelCount: 0,
auth: "Connected",
});
});
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(page.getByLabel("Models")).toHaveCount(0);
await page.getByLabel("API key").fill("sk-or-default-name");
await page.getByRole("button", { name: "Save provider", exact: true }).click();
await expectModelProviderListed(page, {
name: DEFAULT_OPENROUTER_PROVIDER,
providerLabel: "OpenRouter",
modelCount: 0,
auth: "Connected",
});
});
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await startOAuthProviderSignIn(page, {
catalogId: "chatgpt",
});
});
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByLabel("Provider name")).toHaveCount(0);
});
test("shows user-facing provider picker rows", async ({ page }) => {
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
const picker = page.getByTestId("paseo-agent-provider-picker");
await expect(
page
.getByTestId("paseo-agent-catalog-entry-openrouter")
.getByText("API key", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
await expect(
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
});
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
providerNamesToCleanup.add(CHATGPT_PROVIDER);
await seedChatGptProvider(CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await expectModelProviderListed(page, {
name: CHATGPT_PROVIDER,
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
});
test("renames a configured provider row without changing its stored credential key", async ({
page,
}) => {
providerNamesToCleanup.add(RENAME_PROVIDER);
await seedChatGptProvider(RENAME_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
await page.getByLabel("Provider name").fill("Work account");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expectModelProviderListed(page, {
name: "Work account",
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
});
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
providerNamesToCleanup.add(CLOSE_PROVIDER);
await seedChatGptProvider(CLOSE_PROVIDER);
await openPaseoAgentSettings(page);
await expectModelProviderListed(page, {
name: CLOSE_PROVIDER,
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
await page.getByLabel("Close", { exact: true }).click();
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
});
});
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expect(row).toContainText("ten-second-stream");
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
@@ -5,6 +5,7 @@ import {
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
@@ -81,9 +82,9 @@ test.describe("Schedules project target", () => {
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
@@ -126,8 +127,10 @@ test.describe("Schedules project target", () => {
label: "Fake host",
port: fakePort,
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /select project/i }).click();
+5 -8
View File
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
test.describe("Model B sidebar shape", () => {
test.describe.configure({ timeout: 180_000 });
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
page,
}) => {
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
@@ -62,17 +62,14 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
// The per-project "+ New workspace" row is gone. The git project keeps a
// per-row new-worktree icon (revealed on hover); the non-git project has
// none, since worktree creation needs a git checkout.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
// The global new-workspace button is the universal entry — present for both
// kinds regardless of their per-row affordance.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.2",
"version": "0.1.103",
"private": true,
"main": "index.ts",
"scripts": {
@@ -9,7 +9,7 @@ import {
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -36,6 +36,13 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
return "Review";
}
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
fix: "workspace.browser.annotate.intents.fix",
change: "workspace.browser.annotate.intents.change",
question: "workspace.browser.annotate.intents.question",
approve: "workspace.browser.annotate.intents.approve",
};
function getTextAttachmentSubtitle(
attachment: Extract<AgentAttachment, { type: "text" }>,
t: TFunction,
@@ -89,10 +96,11 @@ export function getWorkspaceAttachmentPillContent(
t: TFunction,
): AttachmentPillContent {
if (attachment.kind === "browser_element") {
const intent = attachment.attachment.intent;
return {
icon: attachmentBrowserIcon,
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
+8
View File
@@ -21,6 +21,12 @@ export interface AttachmentMetadata {
createdAt: number;
}
/**
* The kind of review feedback the user is attaching to a selected browser
* element, sent to the agent alongside the element context.
*/
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
export interface BrowserElementAttachment {
url: string;
selector: string;
@@ -44,6 +50,8 @@ export interface BrowserElementAttachment {
children: string[];
/** Free-text review note the user wrote about this element, if any. */
comment?: string;
/** What the user wants the agent to do with this element, if annotated. */
intent?: BrowserAnnotationIntent;
/**
* Cropped screenshot of the selected element, sent to the agent as an image
* alongside the textual element context. Persisted via the attachment store;
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
@@ -9,6 +8,20 @@ import {
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
vi.mock("expo-router", () => ({
router: {
navigate: vi.fn(),
},
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
@@ -17,28 +30,9 @@ type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
class FakeStateStorage implements StateStorage {
private readonly values = new Map<string, string>();
public getItem = (key: string): string | null => this.values.get(key) ?? null;
public setItem = (key: string, value: string): void => {
this.values.set(key, value);
};
public removeItem = (key: string): void => {
this.values.delete(key);
};
public clear(): void {
this.values.clear();
}
}
class FakeDaemonClient {
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
public sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
@@ -61,97 +55,6 @@ class FakeDaemonClient {
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
this.handler?.(nextRequest);
}
public payloadAt(index: number): BrowserAutomationResponsePayload {
const response = this.sentResponses[index];
if (!response) {
throw new Error(`Missing browser automation response at index ${index}`);
}
return response.payload;
}
}
class FakeBrowserBridge {
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
[];
public readonly activeWorkspaceBrowsers: Array<{
browserId: string | null;
workspaceId: string;
}> = [];
public response: BrowserAutomationResponsePayload | null = null;
public thrownError: unknown = null;
public executeAutomationCommand = async (
request: BrowserAutomationExecuteRequest,
): Promise<BrowserAutomationResponsePayload> => {
this.executedRequests.push(request);
if (this.thrownError) {
throw this.thrownError;
}
return this.response ?? currentListTabsPayload(request.requestId);
};
public registerWorkspaceBrowser = async (input: {
browserId: string;
workspaceId: string;
}): Promise<void> => {
this.registeredWorkspaceBrowsers.push(input);
};
public setWorkspaceActiveBrowser = async (input: {
browserId: string | null;
workspaceId: string;
}): Promise<void> => {
this.activeWorkspaceBrowsers.push(input);
};
}
class FakeResidentBrowser {
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
this.ensuredWebviews.push(input);
return null;
};
}
class BrowserAutomationHandlerHarness {
public readonly client = new FakeDaemonClient();
public readonly browser = new FakeBrowserBridge();
public readonly resident = new FakeResidentBrowser();
private unsubscribe: (() => void) | null = null;
public mount(
input: {
serverId?: string;
host?: DesktopHostBridge | null;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
} = {},
): void {
this.unsubscribe = mountBrowserAutomationHandler({
client: this.client,
...(input.serverId ? { serverId: input.serverId } : {}),
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
ensureResidentBrowserWebview: this.resident.ensure,
...(input.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
: {}),
...(input.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
: {}),
});
}
public unmount(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
public receive(request: BrowserAutomationExecuteRequest): void {
this.client.receive(request);
}
}
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
@@ -167,40 +70,45 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "new_tab",
args: { url: "https://example.com" },
},
workspaceId: "/repo",
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true,
ok: true as const,
result: {
command: "list_tabs",
command: "list_tabs" as const,
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
function currentListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true,
ok: true as const,
result: {
command: "list_tabs",
command: "list_tabs" as const,
tabs: currentBrowserTabs(),
},
};
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
function currentBrowserTabs() {
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
browserId: browser.browserId,
workspaceId: "wks_workspace_a",
workspaceId: "/repo",
url: browser.url,
title: browser.title,
isActive: true,
@@ -208,170 +116,151 @@ function currentBrowserTabs() {
}));
}
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
expect(payload).toMatchObject({
requestId: "req-new",
ok: true,
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
});
if (!payload.ok || payload.result.command !== "new_tab") {
throw new Error("Expected browser_new_tab success payload");
}
return payload.result;
}
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
return useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForRegistrationTimeout(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
const browserAutomationStorage = new FakeStateStorage();
useBrowserStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
useWorkspaceLayoutStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
describe("mountBrowserAutomationHandler", () => {
beforeEach(() => {
browserAutomationStorage.clear();
useBrowserStore.setState({ browsersById: {} });
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
test("browser_new_tab creates and focuses a workspace browser tab", async () => {
const browser = new BrowserAutomationHandlerHarness();
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
const client = new FakeDaemonClient();
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
const setAgentActiveBrowser = vi.fn(async () => undefined);
const registerWorkspaceBrowser = vi.fn(async () => undefined);
const ensureResidentBrowserWebview = vi.fn();
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
workspaceId: "/repo",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
if (!workspaceKey) throw new Error("expected workspace key");
const focusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
browser.mount({ serverId: "server-1" });
if (!focusedTabId) throw new Error("expected focused tab");
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () =>
({
browser: {
executeAutomationCommand,
registerWorkspaceBrowser,
setWorkspaceActiveBrowser,
setAgentActiveBrowser,
},
}) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
});
browser.receive(browserNewTabRequest());
await flushAsyncWork();
client.receive(browserNewTabRequest());
await flushPromises();
const result = newTabResultFrom(browser.client.payloadAt(0));
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(openedTabs).toEqual([
expect.objectContaining({
target: { kind: "browser", browserId: result.browserId },
}),
]);
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: openedTabs[0]?.tabId }),
}),
const payload = client.sentResponses[0]?.payload;
expect(payload?.ok).toBe(true);
if (!payload?.ok) throw new Error("expected success");
expect(payload.result.command).toBe("new_tab");
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
expect(payload.result.url).toBe("https://example.com");
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
);
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.resident.ensuredWebviews).toEqual([
{ browserId: result.browserId, url: "https://example.com" },
]);
expect(browser.browser.executedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-new:list_tabs",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: { command: "list_tabs", args: {} },
},
]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout?.root.kind).toBe("pane");
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
browserId: payload.result.browserId,
workspaceId: "/repo",
});
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
agentId: "agent-1",
browserId: payload.result.browserId,
});
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
browserId: payload.result.browserId,
url: "https://example.com",
});
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
});
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = emptyListTabsPayload();
browser.mount({
test("returns browser_timeout when the resident webview does not register", async () => {
const client = new FakeDaemonClient();
const ensureResidentBrowserWebview = vi.fn();
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs: 1,
registrationPollIntervalMs: 1,
});
browser.receive(browserNewTabRequest());
await waitForRegistrationTimeout();
client.receive(browserNewTabRequest());
await waitForAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
message: expect.stringContaining("Timed out waiting for browser tab"),
retryable: true,
},
},
expect(client.sentResponses[0]?.payload).toMatchObject({
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
retryable: true,
},
]);
expect(browser.resident.ensuredWebviews).toEqual([
});
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
expect.objectContaining({ url: "https://example.com" }),
]);
});
test("browser_new_tab wraps registration bridge errors in a response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error("IPC registration check failed");
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
retryable: false,
},
},
},
]);
});
test("non-new-tab requests send the desktop bridge response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = {
requestId: "desktop-req",
);
expect(client.sentResponses[0]?.payload).not.toMatchObject({
ok: true,
result: { command: "list_tabs", tabs: [] },
};
browser.mount();
const request = browserAutomationRequest();
result: { command: "new_tab" },
});
});
browser.receive(request);
await flushAsyncWork();
test("wraps browser_new_tab registration bridge errors in a response", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => {
throw new Error("IPC registration check failed");
});
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
});
expect(browser.browser.executedRequests).toEqual([request]);
expect(browser.client.sentResponses).toEqual([
client.receive(browserNewTabRequest());
await flushPromises();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-new",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
retryable: false,
},
});
});
test("sends a success response from the desktop bridge", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => ({
requestId: "req-1",
ok: true as const,
result: { command: "list_tabs" as const, tabs: [] },
}));
mountBrowserAutomationHandler({
client,
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
});
client.receive(browserAutomationRequest());
await flushPromises();
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
expect(client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -383,14 +272,14 @@ describe("mountBrowserAutomationHandler", () => {
]);
});
test("missing desktop bridge sends browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ host: null });
test("missing bridge sends browser_unsupported", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({ client, getHost: () => null });
browser.receive(browserAutomationRequest());
await flushAsyncWork();
client.receive(browserAutomationRequest());
await flushPromises();
expect(browser.client.sentResponses).toEqual([
expect(client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -407,68 +296,80 @@ describe("mountBrowserAutomationHandler", () => {
});
test("typed bridge errors become failure responses", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({
client,
getHost: () => ({
browser: {
executeAutomationCommand: async () => {
throw {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
},
},
}),
});
client.receive(browserAutomationRequest());
await flushPromises();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
},
]);
});
});
test("unimplemented preload IPC reports browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error(
'No handler registered for "paseo:browser:execute-automation-command"',
);
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
retryable: false,
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({
client,
getHost: () => ({
browser: {
executeAutomationCommand: async () => {
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
},
},
}),
});
client.receive(browserAutomationRequest());
await flushPromises();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
retryable: false,
},
]);
});
});
test("unsubscribe stops handling browser automation requests", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount();
test("unsubscribe stops handling requests", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => ({
requestId: "req-1",
ok: true as const,
result: { command: "list_tabs" as const, tabs: [] },
}));
const unsubscribe = mountBrowserAutomationHandler({
client,
getHost: () => ({ browser: { executeAutomationCommand } }),
});
browser.unmount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
unsubscribe();
client.receive(browserAutomationRequest());
await flushPromises();
expect(browser.browser.executedRequests).toEqual([]);
expect(browser.client.sentResponses).toEqual([]);
expect(executeAutomationCommand).not.toHaveBeenCalled();
expect(client.sentResponses).toEqual([]);
});
});
+33 -10
View File
@@ -40,7 +40,7 @@ export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
return options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
@@ -56,9 +56,6 @@ export function mountBrowserAutomationHandler(
: {}),
});
});
return () => {
unsubscribe();
};
}
export function mountBrowserAutomationDaemonClientHandler(
@@ -114,6 +111,8 @@ async function handleBrowserAutomationRequest(params: {
return;
}
await rememberAgentBrowserTarget({ request, browserHost });
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
@@ -160,11 +159,11 @@ async function openBrowserTabForRequest(params: {
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId;
const workspaceId = request.workspaceId ?? command.args.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
@@ -175,17 +174,19 @@ async function openBrowserTabForRequest(params: {
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
kind: "browser",
browserId,
});
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
await browserHost?.setWorkspaceActiveBrowser?.({ browserId, workspaceId });
if (request.agentId) {
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
}
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
@@ -234,7 +235,7 @@ async function waitForBrowserRegistration(params: {
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
command: { command: "list_tabs", args: {} },
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
});
if (payload.ok && payload.result.command === "list_tabs") {
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
@@ -250,6 +251,28 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function rememberAgentBrowserTarget(input: {
request: BrowserAutomationExecuteRequest;
browserHost: DesktopHostBridge["browser"] | undefined;
}): Promise<void> {
if (!input.request.agentId) {
return;
}
const browserId = readRequestBrowserId(input.request);
if (!browserId) {
return;
}
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
}
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
if (request.browserId) {
return request.browserId;
}
const args = request.command.args as { browserId?: unknown };
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
}
function normalizeBridgePayload(
requestId: string,
payload: BrowserAutomationResponsePayload,
@@ -12,22 +12,21 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
import {
ArrowLeft,
ArrowRight,
Camera,
ChevronDown,
Copy,
Maximize,
Monitor,
MousePointer2,
PencilRuler,
RotateCw,
Smartphone,
Tablet,
Wrench,
X,
type LucideIcon,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import * as Clipboard from "expo-clipboard";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/contexts/toast-context";
import {
@@ -41,7 +40,11 @@ import {
useWorkspaceAttachments,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
import type {
AttachmentMetadata,
BrowserAnnotationIntent,
BrowserElementAttachment,
} from "@/attachments/types";
import { persistAttachmentFromDataUrl } from "@/attachments/service";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
@@ -49,6 +52,7 @@ import {
isElectronRuntime,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
import {
prepareBrowserWebview,
@@ -75,14 +79,25 @@ type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
type BrowserElementSelection = Omit<
BrowserElementAttachment,
"formatted" | "comment" | "intent"
> & {
attributes?: Record<string, string>;
};
interface BrowserElementAnnotation {
comment: string;
intent: BrowserAnnotationIntent;
}
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
"fix",
"change",
"question",
"approve",
];
type DeviceSizeId =
| "responsive"
| "iphone-se"
@@ -208,6 +223,10 @@ function formatElementAttachment(
const html = truncateText(selection.outerHTML.trim(), 800);
const parts: string[] = [];
if (annotation) {
parts.push(`intent: ${annotation.intent}`);
}
if (selection.reactSource?.fileName) {
const loc = [
selection.reactSource.fileName,
@@ -270,6 +289,7 @@ function buildBrowserElementAttachment(
parentChain: selection.parentChain,
children: selection.children,
...(comment ? { comment } : {}),
...(annotation ? { intent: annotation.intent } : {}),
...(screenshot ? { screenshot } : {}),
formatted: formatElementAttachment(selection, annotation),
};
@@ -621,11 +641,10 @@ export function BrowserPane({
const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false);
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
const selectorActive = selectorMode !== null;
const [selectorActive, setSelectorActive] = useState(false);
// Which action the active selector performs on click: open the annotation card
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
// ("annotate") or copy the element to the clipboard ("grab").
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
const toast = useToast();
const toastRef = useRef(toast);
toastRef.current = toast;
@@ -1055,7 +1074,7 @@ export function BrowserPane({
[],
);
const screenshotElementToClipboard = useCallback(
const grabElementToClipboard = useCallback(
async (selection: BrowserElementSelection) => {
const text = formatElementAttachment(selection);
const copyElement = getDesktopHost()?.browser?.copyElement;
@@ -1068,23 +1087,19 @@ export function BrowserPane({
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
imageDataUrl = dataUrl ?? undefined;
} catch (error) {
console.warn("[browser-pane] capture element for screenshot failed", error);
console.warn("[browser-pane] capture element for grab failed", error);
}
}
const copiedMessage = imageDataUrl
? t("workspace.browser.controls.screenshotCopied")
: t("workspace.browser.controls.elementCopied");
// Copy via the main process; the renderer's navigator.clipboard rejects
// with NotAllowedError because focus is inside the guest <webview>.
if (typeof copyElement === "function") {
try {
const ok = await copyElement({ text, imageDataUrl });
if (ok) {
toastRef.current?.show(copiedMessage, { variant: "success" });
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
} else {
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
}
return;
} catch (error) {
@@ -1095,12 +1110,10 @@ export function BrowserPane({
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
try {
await Clipboard.setStringAsync(text);
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
variant: "success",
});
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
} catch (error) {
console.warn("[browser-pane] clipboard fallback failed", error);
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
}
},
[t],
@@ -1108,8 +1121,8 @@ export function BrowserPane({
const handleSelectorResult = useCallback(
(selection: BrowserElementSelection) => {
if (selectorModeRef.current === "screenshot") {
void screenshotElementToClipboard(selection);
if (selectorModeRef.current === "grab") {
void grabElementToClipboard(selection);
return;
}
pendingScreenshotRef.current = undefined;
@@ -1119,7 +1132,7 @@ export function BrowserPane({
return undefined;
});
},
[captureElementScreenshot, screenshotElementToClipboard],
[captureElementScreenshot, grabElementToClipboard],
);
const submitAnnotation = useCallback(
@@ -1142,15 +1155,15 @@ export function BrowserPane({
}, []);
const startElementSelector = useCallback(
(mode: "annotate" | "screenshot") => {
(mode: "annotate" | "grab") => {
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) return;
// Annotate needs a workspace scope to attach to; screenshot only copies.
// Annotate needs a workspace scope to attach to; grab only copies.
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
selectorModeRef.current = mode;
pendingScreenshotRef.current = undefined;
setPendingSelection(null);
setSelectorMode(mode);
setSelectorActive(true);
const js = `
(function() {
@@ -1376,11 +1389,11 @@ export function BrowserPane({
const poll = startSelectorResultPolling({
webview,
onSelection: handleSelectorResult,
onDone: () => setSelectorMode(null),
onDone: () => setSelectorActive(false),
});
window.setTimeout(() => {
window.clearInterval(poll);
setSelectorMode(null);
setSelectorActive(false);
if (webviewRef.current !== webview || !domReadyRef.current) {
return;
}
@@ -1389,10 +1402,10 @@ export function BrowserPane({
return undefined;
})
.catch(() => {
setSelectorMode(null);
setSelectorActive(false);
});
} catch {
setSelectorMode(null);
setSelectorActive(false);
}
},
[handleSelectorResult, workspaceAttachmentScopeKey],
@@ -1400,7 +1413,7 @@ export function BrowserPane({
const cancelElementSelector = useCallback(() => {
const webview = webviewRef.current;
setSelectorMode(null);
setSelectorActive(false);
if (webview && domReadyRef.current) {
try {
clearWebviewSelector(webview);
@@ -1457,12 +1470,12 @@ export function BrowserPane({
startElementSelector("annotate");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleToggleScreenshot = useCallback(() => {
const handleToggleGrab = useCallback(() => {
if (selectorActive) {
cancelElementSelector();
return;
}
startElementSelector("screenshot");
startElementSelector("grab");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleOpenDevTools = useCallback(() => {
@@ -1508,21 +1521,13 @@ export function BrowserPane({
],
[browser?.canGoForward],
);
const annotateIconButtonStyle = useCallback(
const selectorIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorMode === "annotate" && styles.selectorActiveButton,
selectorActive && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorMode],
);
const screenshotIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorMode === "screenshot" && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorMode],
[selectorActive],
);
const devicePreset = useMemo(
@@ -1628,46 +1633,36 @@ export function BrowserPane({
onSelect={setDeviceSizeId}
triggerStyle={baseIconButtonStyle}
/>
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<Wrench size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
{isDev ? (
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
) : null}
<ToolbarButton
label={
selectorMode === "annotate"
selectorActive
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.annotateElement")
: t("workspace.browser.controls.selectElement")
}
active={selectorMode === "annotate"}
active={selectorActive}
onPress={handleToggleElementSelector}
style={annotateIconButtonStyle}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</ToolbarButton>
<ToolbarButton
label={
selectorMode === "screenshot"
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.screenshotElement")
}
active={selectorMode === "screenshot"}
onPress={handleToggleScreenshot}
style={screenshotIconButtonStyle}
label={t("workspace.browser.controls.grabElement")}
onPress={handleToggleGrab}
style={baseIconButtonStyle}
>
<Camera
size={16}
color={
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
}
/>
<Copy size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
</View>
</View>
@@ -1695,6 +1690,45 @@ export function BrowserPane({
);
}
const INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
fix: "workspace.browser.annotate.intents.fix",
change: "workspace.browser.annotate.intents.change",
question: "workspace.browser.annotate.intents.question",
approve: "workspace.browser.annotate.intents.approve",
};
function IntentChip({
active,
intent,
label,
onSelect,
}: {
active: boolean;
intent: BrowserAnnotationIntent;
label: string;
onSelect: (intent: BrowserAnnotationIntent) => void;
}) {
const handlePress = useCallback(() => {
onSelect(intent);
}, [intent, onSelect]);
const chipStyle = useMemo(() => [styles.intentChip, active && styles.intentChipActive], [active]);
const textStyle = useMemo(
() => [styles.intentChipText, active && styles.intentChipTextActive],
[active],
);
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
function BrowserElementAnnotationCard({
selection,
onSubmit,
@@ -1706,11 +1740,14 @@ function BrowserElementAnnotationCard({
}) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
const commentRef = useRef(comment);
commentRef.current = comment;
const intentRef = useRef(intent);
intentRef.current = intent;
const handleSubmit = useCallback(() => {
onSubmit({ comment: commentRef.current });
onSubmit({ comment: commentRef.current, intent: intentRef.current });
}, [onSubmit]);
useEffect(() => {
@@ -1755,6 +1792,17 @@ function BrowserElementAnnotationCard({
<Text numberOfLines={1} style={styles.annotationElement}>
{elementLabel}
</Text>
<View style={styles.annotationIntents}>
{BROWSER_ANNOTATION_INTENTS.map((option) => (
<IntentChip
key={option}
active={option === intent}
intent={option}
label={t(INTENT_LABEL_KEYS[option])}
onSelect={setIntent}
/>
))}
</View>
<ThemedAnnotationInput
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
autoFocus
@@ -1766,12 +1814,24 @@ function BrowserElementAnnotationCard({
value={comment}
/>
<View style={styles.annotationActions}>
<Button variant="ghost" size="sm" onPress={onCancel}>
{t("workspace.browser.annotate.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleSubmit}>
{t("workspace.browser.annotate.submit")}
</Button>
<Pressable
accessibilityRole="button"
onPress={onCancel}
style={styles.annotationSecondaryButton}
>
<Text style={styles.annotationSecondaryText}>
{t("workspace.browser.annotate.cancel")}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={handleSubmit}
style={styles.annotationPrimaryButton}
>
<Text style={styles.annotationPrimaryText}>
{t("workspace.browser.annotate.submit")}
</Text>
</Pressable>
</View>
</View>
</View>
@@ -1926,7 +1986,31 @@ const styles = StyleSheet.create((theme) => ({
annotationElement: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginBottom: theme.spacing[2],
},
annotationIntents: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[1],
},
intentChip: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
intentChipActive: {
borderColor: theme.colors.accent,
backgroundColor: `${String(theme.colors.accent)}20`,
},
intentChipText: {
fontSize: theme.fontSize.xs,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
intentChipTextActive: {
color: theme.colors.accent,
},
annotationInput: {
minHeight: 64,
@@ -1945,6 +2029,27 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "flex-end",
gap: theme.spacing[2],
},
annotationSecondaryButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
annotationSecondaryText: {
fontSize: theme.fontSize.sm,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
annotationPrimaryButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.accent,
},
annotationPrimaryText: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.accentForeground ?? "#ffffff",
},
unavailableState: {
flex: 1,
alignItems: "center",
@@ -2,66 +2,33 @@ import { afterEach, describe, expect, it } from "vitest";
import {
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
prepareBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
function residentHost(): HTMLElement {
const host = document.getElementById(RESIDENT_HOST_ID);
if (!host) {
throw new Error("Expected resident browser host");
}
return host;
}
function expectPermanentHostParking(host: HTMLElement): void {
expect(host.style.position).toBe("fixed");
expect(host.style.left).toBe("0px");
expect(host.style.top).toBe("0px");
expect(host.style.width).toBe("1px");
expect(host.style.height).toBe("1px");
expect(host.style.overflow).toBe("hidden");
expect(host.style.opacity).toBe("1");
expect(host.style.pointerEvents).toBe("none");
expect(host.style.display).toBe("block");
expect(host.style.visibility).toBe("visible");
expect(host.style.transform).toBe("");
}
function expectResidentWebviewParking(webview: HTMLElement): void {
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.flex).toBe("0 0 auto");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
}
describe("resident browser webviews", () => {
afterEach(() => {
clearResidentBrowserWebviewsForTests();
});
it("parks a browser webview in the permanent paintable 1x1 host", () => {
const visibleHost = document.createElement("div");
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
const host = document.createElement("div");
const webview = document.createElement("webview");
visibleHost.appendChild(webview);
document.body.appendChild(visibleHost);
host.appendChild(webview);
document.body.appendChild(host);
releaseResidentBrowserWebview("browser-a", webview);
const host = residentHost();
expect(visibleHost.children).toHaveLength(0);
expect(Array.from(host.children)).toEqual([webview]);
expect(host.children).toHaveLength(0);
expect(webview.isConnected).toBe(true);
expectPermanentHostParking(host);
expectResidentWebviewParking(webview);
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
const reused = takeResidentBrowserWebview("browser-a");
expect(reused).toBe(webview);
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
});
it("creates a resident webview for an agent-created unfocused tab", () => {
@@ -77,113 +44,6 @@ describe("resident browser webviews", () => {
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com",
);
expectPermanentHostParking(residentHost());
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident host back to permanent parking", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
document.body.appendChild(staleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-stale-host",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident webview and its stale host before reusing them", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
const staleWebview = document.createElement("webview");
prepareBrowserWebview(staleWebview, {
browserId: "browser-stale-child",
initialUrl: "https://example.com",
});
staleWebview.style.display = "none";
staleWebview.style.width = "1px";
staleWebview.style.height = "1px";
staleWebview.style.position = "fixed";
staleWebview.style.left = "-20000px";
staleHost.appendChild(staleWebview);
document.body.appendChild(staleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-stale-child",
url: "https://example.com/agent",
});
expect(webview).toBe(staleWebview);
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(staleWebview);
});
it("parks resident webviews as an overlapping stack", () => {
const firstWebview = ensureResidentBrowserWebview({
browserId: "browser-first",
url: "https://example.com/first",
});
const secondWebview = ensureResidentBrowserWebview({
browserId: "browser-second",
url: "https://example.com/second",
});
const host = residentHost();
expect(firstWebview?.parentElement).toBe(host);
expect(secondWebview?.parentElement).toBe(host);
expectResidentWebviewParking(firstWebview as HTMLElement);
expectResidentWebviewParking(secondWebview as HTMLElement);
});
it("moves a resident webview into a visible pane without recreating the node", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible",
url: "https://example.com",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview?.style.position).toBe("");
expect(webview?.style.left).toBe("");
expect(webview?.style.top).toBe("");
expect(webview?.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
});
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareBrowserWebview(visibleWebview, {
browserId: "browser-visible-pane",
initialUrl: "https://example.com",
});
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible-pane",
url: "https://example.com/agent",
});
expect(webview).toBe(visibleWebview);
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
});
it("removes a resident webview when its browser tab closes", () => {
@@ -21,71 +21,42 @@ function readDocument(): Document | null {
return typeof document === "undefined" ? null : document;
}
function applyResidentHostParkingStyle(host: HTMLElement): void {
// Parked browser webviews must remain paintable at all times; screenshot
// correctness depends on the proven states in docs/browser-capture-harness.md.
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "visible";
host.style.transform = "";
}
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) {
applyResidentHostParkingStyle(existing);
return existing;
}
const host = ownerDocument.createElement("div");
host.id = RESIDENT_BROWSER_HOST_ID;
applyResidentHostParkingStyle(host);
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
ownerDocument.body.appendChild(host);
return host;
}
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
return element;
return element as HTMLElement;
}
}
return null;
}
function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.display = "block";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = "0";
}
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = "";
webview.style.zIndex = "";
}
export function prepareBrowserWebview(
@@ -117,15 +88,11 @@ export function ensureResidentBrowserWebview(input: {
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
releaseResidentBrowserWebview(browserId, resident);
return resident;
}
const existing = findBrowserWebview(browserId, ownerDocument);
if (existing) {
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
releaseResidentBrowserWebview(browserId, existing);
}
return existing;
}
@@ -147,7 +114,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
@@ -1,118 +0,0 @@
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,
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
}
/**
* The "All hosts / <host>" 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.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
triggerTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
}));
@@ -0,0 +1,20 @@
import Svg, { Path } from "react-native-svg";
interface OpenRouterIconProps {
size?: number;
color?: string;
}
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import {
createPaseoAgentProviderInput,
getPaseoAgentApiKeyAuth,
getPaseoAgentOAuthAuth,
isPaseoAgentCatalogEntrySupported,
nextPaseoAgentProviderName,
parsePaseoAgentModelIds,
paseoAgentAuthBadge,
paseoAgentProviderLabel,
preferredPaseoAgentOAuthMode,
} from "./paseo-agent-settings-sheet-model";
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
return {
id: "catalog-alpha",
label: "Catalog Alpha",
api: "responses",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
...overrides,
};
}
function providerConfig(
overrides: Partial<RedactedPaseoAgentProviderConfig>,
): RedactedPaseoAgentProviderConfig {
return {
name: "catalog-alpha",
providerType: "catalog-alpha",
models: [{ id: "alpha-fast" }],
available: true,
...overrides,
};
}
describe("paseo-agent-settings-sheet-model", () => {
it("recognizes supported catalog auth manifests", () => {
const apiKeyEntry = catalogEntry({
auth: {
kind: "api_key",
envVar: "ALPHA_API_KEY",
keyUrl: "https://alpha.example.test/key",
placeholder: "alpha-key",
hint: "Paste the key from Alpha",
},
});
const oauthEntry = catalogEntry({
auth: { kind: "oauth", flow: "alpha-oauth" },
});
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
kind: "api_key",
envVar: "ALPHA_API_KEY",
keyUrl: "https://alpha.example.test/key",
placeholder: "alpha-key",
hint: "Paste the key from Alpha",
});
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
kind: "oauth",
flow: "alpha-oauth",
});
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
});
it("keeps unknown catalog auth kinds visible but unsupported", () => {
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
});
it("prefers device-code OAuth only for a relay connection", () => {
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
});
it("parses model ids from comma and newline separated input", () => {
expect(
parsePaseoAgentModelIds(`
alpha/fast, beta/steady
alpha/fast
gamma/deep
`),
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
});
it("uses the catalog label for the first provider instance", () => {
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
"Acme Cloud",
);
});
it("numbers later provider instances from the catalog label", () => {
expect(
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
providerConfig({ name: "Acme Cloud" }),
providerConfig({ name: "Acme Cloud (1)" }),
]),
).toBe("Acme Cloud (2)");
});
it("builds a generic provider payload with a trimmed explicit key", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ id: "catalog-beta" }),
name: " beta-main ",
apiKey: " beta-secret ",
}),
).toEqual({
name: "beta-main",
providerType: "catalog-beta",
options: {
apiKey: "beta-secret",
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("builds a generic provider payload with custom model ids", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ models: [] }),
name: "alpha-main",
apiKey: "alpha-secret",
modelIds: ["alpha/fast", "beta/steady"],
}),
).toEqual({
name: "alpha-main",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
},
});
});
it("omits model overrides when the catalog has no defaults", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ models: [] }),
name: "alpha-main",
apiKey: "alpha-secret",
}),
).toEqual({
name: "alpha-main",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
},
});
});
it("builds a generic provider payload with an env reference for an empty key", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
name: "beta-main",
apiKey: " ",
}),
).toEqual({
name: "beta-main",
providerType: "catalog-alpha",
options: {
apiKey: "$BETA_API_KEY",
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("builds an oauth provider payload without key material", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
name: "alpha-login",
}),
).toEqual({
name: "alpha-login",
providerType: "catalog-alpha",
options: {
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("uses catalog labels and generic auth badges for instance rows", () => {
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
expect(
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
).toBe("custom-alpha");
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
label: "Connected",
variant: "success",
});
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
label: "Needs attention",
variant: "error",
});
expect(paseoAgentAuthBadge(undefined)).toBeNull();
});
});
@@ -0,0 +1,139 @@
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
export interface PaseoAgentApiKeyAuthManifest {
kind: "api_key";
envVar: string;
keyUrl?: string;
placeholder?: string;
hint?: string;
}
export interface PaseoAgentOAuthAuthManifest {
kind: "oauth";
flow: string;
}
export interface PaseoAgentAuthBadge {
label: string;
variant: "success" | "error" | "muted";
}
export type PaseoAgentOAuthMode = "browser" | "device_code";
export interface PaseoAgentOAuthConnectionSignal {
type: string;
}
export function preferredPaseoAgentOAuthMode(
activeConnection: PaseoAgentOAuthConnectionSignal | null,
): PaseoAgentOAuthMode {
return activeConnection?.type === "relay" ? "device_code" : "browser";
}
export function getPaseoAgentApiKeyAuth(
entry: PaseoAgentCatalogEntry,
): PaseoAgentApiKeyAuthManifest | null {
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
return null;
}
return {
kind: "api_key",
envVar: entry.auth.envVar,
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
};
}
export function getPaseoAgentOAuthAuth(
entry: PaseoAgentCatalogEntry,
): PaseoAgentOAuthAuthManifest | null {
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
return null;
}
return { kind: "oauth", flow: entry.auth.flow };
}
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
}
export function paseoAgentProviderLabel(
provider: RedactedPaseoAgentProviderConfig,
catalogEntry: PaseoAgentCatalogEntry | undefined,
): string {
return catalogEntry?.label ?? provider.providerType;
}
export function paseoAgentAuthBadge(
auth: RedactedPaseoAgentProviderConfig["auth"],
): PaseoAgentAuthBadge | null {
if (!auth || auth.kind === "none") {
return null;
}
return auth.configured
? { label: "Connected", variant: "success" }
: { label: "Needs attention", variant: "error" };
}
export function parsePaseoAgentModelIds(raw: string): string[] {
const seen = new Set<string>();
const ids: string[] = [];
for (const part of raw.split(/[\n,]/)) {
const id = part.trim();
if (id.length > 0 && !seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
return ids;
}
export function nextPaseoAgentProviderName(
entry: PaseoAgentCatalogEntry,
providers: RedactedPaseoAgentProviderConfig[],
): string {
const baseName = entry.label.trim() || entry.id;
const existingNames = new Set(providers.map((provider) => provider.name));
if (!existingNames.has(baseName)) {
return baseName;
}
for (let index = 1; ; index += 1) {
const candidate = `${baseName} (${index})`;
if (!existingNames.has(candidate)) {
return candidate;
}
}
}
export function createPaseoAgentProviderInput(input: {
entry: PaseoAgentCatalogEntry;
name: string;
apiKey?: string;
modelIds?: string[];
}): PaseoAgentSetProviderInput {
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
const trimmedKey = input.apiKey?.trim() ?? "";
let apiKey: string | undefined;
if (apiKeyAuth) {
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
}
const models =
input.modelIds && input.modelIds.length > 0
? input.modelIds.map((id) => ({ id }))
: input.entry.models.map((model) => ({ ...model }));
return {
name: input.name.trim(),
providerType: input.entry.id,
options: {
...(models.length > 0 ? { models } : {}),
...(apiKey ? { apiKey } : {}),
},
};
}
@@ -0,0 +1,571 @@
/**
* @vitest-environment jsdom
*/
import React, { type ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import type {
PaseoAgentOAuthCompleteResult,
PaseoAgentOAuthStartResult,
PaseoAgentSetProviderInput,
} from "@/hooks/use-paseo-agent-providers";
interface PaseoAgentProvidersHookMock {
supported: boolean;
catalogSupported: boolean;
providers: RedactedPaseoAgentProviderConfig[];
catalog: PaseoAgentCatalogEntry[];
defaultModel: string | null;
isLoading: boolean;
isCatalogLoading: boolean;
error: string | null;
catalogError: string | null;
refresh: () => Promise<void>;
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
current: {
supported: true,
catalogSupported: true,
providers: [] as RedactedPaseoAgentProviderConfig[],
catalog: [] as PaseoAgentCatalogEntry[],
defaultModel: null as string | null,
isLoading: false,
isCatalogLoading: false,
error: null as string | null,
catalogError: null as string | null,
refresh: vi.fn(async () => undefined),
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
startOAuth: vi.fn(
async (): Promise<PaseoAgentOAuthStartResult> => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: null,
error: null,
}),
),
completeOAuth: vi.fn(
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
requestId: "oauth-complete",
success: true,
name: "catalog-login",
auth: { kind: "oauth", configured: true },
error: null,
}),
),
},
};
return {
hookState: initialHookState,
hostRuntimeSnapshot: {
current: {
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
} as {
activeConnection: { type: string; endpoint: string; display: string } | null;
} | null,
},
openExternalUrls: [] as string[],
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
borderRadius: { md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500" },
opacity: { 50: 0.5 },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
border: "#444",
destructive: "#f00",
statusSuccess: "#0f0",
},
},
};
});
vi.mock("react-native", () => ({
View: ({
children,
testID,
role,
accessibilityRole,
accessibilityLabel,
}: {
children?: ReactNode;
testID?: string;
role?: string;
accessibilityRole?: string;
accessibilityLabel?: string;
}) =>
React.createElement(
"div",
{
"data-testid": testID,
role: role ?? accessibilityRole,
"aria-label": accessibilityLabel,
},
children,
),
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
React.createElement("span", { "data-testid": testID }, children),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: {
uniProps?: (theme: unknown) => Record<string, unknown>;
} & Record<string, unknown>) => {
const themed = uniProps ? uniProps(theme) : {};
return React.createElement(Component, { ...rest, ...themed });
},
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
Bot: icon("Bot"),
Plus: icon("Plus"),
};
});
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
header,
children,
footer,
testID,
}: {
visible: boolean;
header?: { title: string; back?: { label?: string; onPress: () => void } };
children: ReactNode;
footer?: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{header?.title}</h1>
{header?.back ? (
<button type="button" onClick={header.back.onPress}>
{header.back.label ?? "Back"}
</button>
) : null}
{children}
{footer ? <footer>{footer}</footer> : null}
</section>
) : null,
AdaptiveTextInput: ({
value,
onChangeText,
accessibilityLabel,
testID,
placeholder,
secureTextEntry,
editable = true,
multiline,
}: {
value?: string;
onChangeText?: (value: string) => void;
accessibilityLabel?: string;
testID?: string;
placeholder?: string;
secureTextEntry?: boolean;
editable?: boolean;
multiline?: boolean;
}) => {
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
onChangeText?.(event.currentTarget.value);
}
const inputProps = {
"aria-label": accessibilityLabel,
"data-testid": testID,
disabled: !editable,
onChange: handleChange,
placeholder,
value: value ?? "",
};
if (multiline) {
return React.createElement("textarea", inputProps);
}
return React.createElement("input", {
...inputProps,
type: secureTextEntry ? "password" : "text",
});
},
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
}));
vi.mock("@/components/provider-icon-name", () => ({
resolveProviderIconName: (provider: string) =>
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
}));
vi.mock("@/components/ui/external-link", () => ({
ExternalLink: ({
href,
label,
testID,
accessibilityLabel,
}: {
href: string;
label: string;
testID?: string;
accessibilityLabel?: string;
}) => (
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
{label}
</a>
),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
disabled,
onPress,
testID,
}: {
children?: ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) => (
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
{children}
</button>
),
}));
vi.mock("@/components/ui/status-badge", () => ({
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
}));
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
usePaseoAgentProviders: () => hookState.current,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: async (url: string) => {
openExternalUrls.push(url);
},
}));
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
return {
id: "catalog-alpha",
label: "Catalog Alpha",
iconName: "known-icon",
docsUrl: "https://alpha.example.test/docs",
api: "responses",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
...overrides,
};
}
function providerConfig(
overrides: Partial<RedactedPaseoAgentProviderConfig>,
): RedactedPaseoAgentProviderConfig {
return {
name: "catalog-alpha",
providerType: "catalog-alpha",
models: [{ id: "alpha-fast" }],
auth: { kind: "api_key", configured: true },
available: true,
...overrides,
};
}
function renderSheet() {
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
}
function resetHookState() {
hookState.current.supported = true;
hookState.current.catalogSupported = true;
hookState.current.providers = [];
hookState.current.catalog = [];
hookState.current.defaultModel = null;
hookState.current.isLoading = false;
hookState.current.isCatalogLoading = false;
hookState.current.error = null;
hookState.current.catalogError = null;
hookState.current.refresh = vi.fn(async () => undefined);
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: null,
error: null,
}));
hookState.current.completeOAuth = vi.fn(async () => ({
requestId: "oauth-complete",
success: true,
name: "catalog-login",
auth: { kind: "oauth", configured: true },
error: null,
}));
hostRuntimeSnapshot.current = {
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
};
openExternalUrls.splice(0);
}
describe("PaseoAgentSettingsSheet", () => {
beforeEach(() => {
resetHookState();
});
afterEach(() => {
cleanup();
});
it("shows the catalog feature-gate message exactly once", () => {
hookState.current.catalogSupported = false;
renderSheet();
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
});
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
hookState.current.catalog = [
catalogEntry({ label: "Alpha Provider" }),
catalogEntry({
id: "catalog-future",
label: "Future Provider",
auth: { kind: "future_auth", prompt: "not yet" },
}),
];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
expect(screen.getByText("Alpha Provider")).toBeTruthy();
expect(screen.getByText("Future Provider")).toBeTruthy();
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
"https://alpha.example.test/docs",
);
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
expect(
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("submits an api-key provider with an explicit key", async () => {
hookState.current.catalog = [catalogEntry({ models: [] })];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
expect(screen.queryByLabelText("Provider name")).toBeNull();
expect(screen.queryByLabelText("Models")).toBeNull();
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: " alpha-secret " },
});
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Alpha",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
},
} satisfies PaseoAgentSetProviderInput);
});
});
it("submits an empty api-key field as a host env reference", async () => {
hookState.current.catalog = [catalogEntry({})];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Alpha",
providerType: "catalog-alpha",
options: {
apiKey: "$ALPHA_API_KEY",
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
},
} satisfies PaseoAgentSetProviderInput);
});
});
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: {
kind: "auth_url",
url: "https://login.example.test/oauth/authorize?state=abc",
instructions: "Open the sign-in page",
},
error: null,
}));
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
await waitFor(() => {
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
});
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
"https://login.example.test/oauth/authorize?state=abc",
);
});
it("renders a device-code oauth authorization and completes it", async () => {
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: {
kind: "device_code",
userCode: "CODE-123",
verificationUri: "https://login.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 600,
instructions: "Enter the code",
},
error: null,
}));
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Login",
providerType: "catalog-login",
options: {
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
},
} satisfies PaseoAgentSetProviderInput);
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
});
});
it("offers device-code first over relay while keeping browser available", () => {
hostRuntimeSnapshot.current = {
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
};
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
const actionButtons = screen
.getByTestId("paseo-agent-oauth-sign-in")
.querySelectorAll("button");
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
"Providers",
"Cancel",
"Use a code instead",
"Sign in with browser",
]);
});
it("shows auth-state badges from configured instances", () => {
hookState.current.catalog = [
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
];
hookState.current.providers = [
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
providerConfig({
name: "beta",
providerType: "catalog-beta",
auth: { kind: "api_key", configured: false },
}),
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
];
renderSheet();
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
expect(screen.getByText("Connected")).toBeTruthy();
expect(screen.getByText("Needs attention")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,17 @@ describe("resolveProviderIconName", () => {
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
});
it("resolves Paseo Agent model-provider catalog icon names", () => {
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
expect(resolveProviderIconName("openrouter")).toEqual({
kind: "builtin",
id: "openrouter",
});
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
@@ -6,7 +6,9 @@ import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "@/components/provider-icon-name";
@@ -25,7 +27,10 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
kiro: PackagePlus,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
openai: CodexIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
paseo: PaseoLogo as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
@@ -1,7 +1,10 @@
import { useCallback } from "react";
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
const PASEO_AGENT_PROVIDER = "paseo";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
@@ -16,6 +19,10 @@ export function ProviderSettingsHost() {
return null;
}
if (provider === PASEO_AGENT_PROVIDER) {
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
}
return (
<ProviderDiagnosticSheet
key={`${serverId}:${provider}`}
@@ -4,6 +4,7 @@ import type { PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isWeb } from "@/constants/platform";
import {
describeCron,
everyMsToParts,
@@ -199,6 +200,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
style={styles.unitControl}
testID="cadence-interval-unit"
/>
</View>
@@ -281,6 +283,8 @@ function CronPresetChip({
);
}
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -304,10 +308,15 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
// Both cadence segmented controls hug their options and stand at the form's
// field height, so the interval row reads as input + toggle rather than a
// full-width track with the controls floating inside it.
modeControl: {
alignSelf: "flex-start",
height: 44,
},
unitControl: {
height: 44,
},
presetRow: {
flexDirection: "row",
@@ -328,11 +337,9 @@ const styles = StyleSheet.create((theme) => ({
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
@@ -340,7 +347,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
color: theme.colors.accentForeground,
},
cronInput: {
minHeight: 44,
@@ -351,7 +358,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
fontFamily: MONOSPACE_FONT,
},
preview: {
fontSize: theme.fontSize.xs,
@@ -363,6 +370,6 @@ const styles = StyleSheet.create((theme) => ({
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],
color: theme.colors.destructive,
},
}));
@@ -24,13 +24,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
buildScheduleProjectTargets,
PROJECT_OPTION_PREFIX,
type ScheduleProjectTarget,
} from "@/schedules/schedule-project-targets";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
@@ -38,6 +32,7 @@ import type { ProjectSummary } from "@/utils/projects";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleFormSheetProps {
serverId?: string;
@@ -47,6 +42,15 @@ export interface ScheduleFormSheetProps {
schedule?: ScheduleSummary;
}
interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
@@ -75,19 +79,44 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
};
}
function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
function buildProjectOptionTestId(optionId: string): string {
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
return `schedule-project-option-${targetKey}`;
}
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
const targets = buildScheduleProjectTargets(projects);
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
const options: ComboboxOption[] = targets.map((target) => ({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
}));
const targets: ScheduleProjectTarget[] = [];
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
const options: ComboboxOption[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
const target: ScheduleProjectTarget = {
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
};
targets.push(target);
targetByOptionId.set(target.optionId, target);
options.push({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(cwd)}`,
});
}
}
return { targets, options, targetByOptionId };
}
@@ -124,35 +153,6 @@ function isSelectedModelValidForProviders(input: {
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
function parseMaxRuns(raw: string): number | null {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function canSubmitScheduleForm(input: {
isAgentTarget: boolean;
isEdit: boolean;
promptTrimmed: string;
cadenceError: string | null;
isSubmitting: boolean;
selectedModelIsValid: boolean;
hasWorkingDir: boolean;
hasSelectedProject: boolean;
}): boolean {
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
return false;
}
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
// non-empty stored cwd; create requires a matched project.
if (input.isAgentTarget) {
return true;
}
if (!input.selectedModelIsValid) {
return false;
}
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
}
export function ScheduleFormSheet({
serverId,
visible,
@@ -161,26 +161,10 @@ export function ScheduleFormSheet({
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
// (service.ts rejects newAgentConfig for them), so the form drops the
// project/model/mode pickers and shows the target agent read-only instead.
const isAgentTarget = isEdit && schedule?.target.type === "agent";
const editConfig = newAgentConfig(schedule);
const { projects } = useProjects();
const { agents } = useAggregatedAgents({ includeArchived: true });
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const agentTargetLabel = useMemo(() => {
if (!schedule || schedule.target.type !== "agent") {
return null;
}
const { agentId } = schedule.target;
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
if (!agent) {
return "Agent unavailable";
}
return agent.title?.trim() || "Untitled agent";
}, [agents, schedule, serverId]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
@@ -213,9 +197,7 @@ export function ScheduleFormSheet({
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerId,
setSelectedServerIdFromUser,
setWorkingDir,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
@@ -237,10 +219,7 @@ export function ScheduleFormSheet({
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
// Compare against the current server, not the matched target: an unmatched
// stored cwd has no target but still lives on a host, and switching hosts
// must still clear a provider/model that may not exist on the new one.
if (selectedServerId && selectedServerId !== target.serverId) {
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
@@ -248,7 +227,7 @@ export function ScheduleFormSheet({
},
[
clearProviderSelectionFromUser,
selectedServerId,
selectedProjectTarget,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
@@ -314,127 +293,90 @@ export function ScheduleFormSheet({
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
// The sheet stays mounted, and the form reducer's reset-on-close only
// clears user-modified flags — not the picker values — so a create opened
// after an edit would inherit that schedule's server/cwd (including a
// stale ghost path). Clear them so create always starts fresh; provider
// and model re-resolve from preferences.
if (!isEdit) {
setSelectedServerId(null);
setWorkingDir("");
}
}
wasVisibleRef.current = visible;
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
}, [visible, schedule]);
const promptTrimmed = prompt.trim();
const trimmedWorkingDir = workingDir.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit = canSubmitScheduleForm({
isAgentTarget,
isEdit,
promptTrimmed,
cadenceError,
isSubmitting,
selectedModelIsValid,
hasWorkingDir: trimmedWorkingDir.length > 0,
hasSelectedProject: Boolean(selectedProjectTarget),
});
const canSubmit =
promptTrimmed.length > 0 &&
selectedModelIsValid &&
Boolean(selectedProjectTarget) &&
cadenceError === null &&
!isSubmitting;
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
return false;
const handleSubmit = useCallback(async () => {
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
return;
}
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
maxRuns: parseMaxRuns(maxRuns),
});
return true;
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
setSubmitError(null);
try {
await persistFormPreferences();
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
const maxRunsValue =
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
// New-agent target: submit the current working directory. On edit an untouched
// picker leaves this as the stored cwd, so it round-trips unchanged.
const submitNewAgent = useCallback(async (): Promise<boolean> => {
if (!selectedProvider || !trimmedWorkingDir) {
return false;
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: selectedProjectTarget.cwd,
},
maxRuns: maxRunsValue,
});
} else {
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: selectedProjectTarget.cwd,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
}
onClose();
} catch (error) {
setSubmitError(toErrorMessage(error));
}
await persistFormPreferences();
const maxRunsValue = parseMaxRuns(maxRuns);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: trimmedWorkingDir,
},
maxRuns: maxRunsValue,
});
return true;
}
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: trimmedWorkingDir,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
return true;
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
onClose,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProjectTarget,
selectedProvider,
selectedThinkingOptionId,
trimmedWorkingDir,
updateSchedule,
]);
const handleSubmit = useCallback(async () => {
if (!promptTrimmed) {
return;
}
setSubmitError(null);
try {
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
if (submitted) {
onClose();
}
} catch (error) {
setSubmitError(toErrorMessage(error));
}
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
@@ -512,53 +454,34 @@ export function ScheduleFormSheet({
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
@@ -581,6 +504,10 @@ export function ScheduleFormSheet({
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{editConfig === null && isEdit ? (
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
) : null}
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
@@ -667,15 +594,12 @@ function ProjectField({
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
/** Stored cwd for an edited schedule whose path matches no known project. */
fallbackCwd: string;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
@@ -705,13 +629,7 @@ function ProjectField({
[open],
);
// Honest hydration: a stored cwd that matches no known project shows the
// shortened path itself (not the blank "Select project"), and stays put until
// the user deliberately picks a project.
const storedPath = fallbackCwd.trim();
const displayValue =
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
const isPlaceholder = !selectedTarget && !storedPath;
const displayValue = selectedTarget?.projectName ?? "Select project";
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
@@ -744,7 +662,7 @@ function ProjectField({
testID="schedule-project-trigger"
>
<Text
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
numberOfLines={1}
>
{displayValue}
@@ -891,20 +809,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.palette.red[300],
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
readonlyField: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -15,9 +15,7 @@ import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -55,62 +53,43 @@ export interface ScheduleRowActions {
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
/** Client-derived target line (agent title / project / shortened path). */
targetLabel: string;
/** Provider glyph, resolved from the schedule config or the target agent. */
provider: string | null;
/** Client-derived state — the single source for the badge and next-run copy. */
state: ScheduleDerivedState;
/** Host name, rendered when the list spans more than one host. */
serverName?: string;
/** True when only one host exists and the host name would be redundant. */
singleHost?: boolean;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function stateBadge(state: ScheduleDerivedState): {
label: string;
variant: "success" | "error" | "muted";
} {
switch (state) {
case "active":
return { label: "Active", variant: "success" };
case "paused":
return { label: "Paused", variant: "muted" };
case "expired":
return { label: "Expired", variant: "muted" };
case "finished":
return { label: "Finished", variant: "muted" };
case "targetGone":
return { label: "Target gone", variant: "error" };
}
function resolveProvider(schedule: ScheduleSummary): string | null {
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
}
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
if (next) {
parts.push(`Next run ${next}`);
}
function resolveModelLabel(schedule: ScheduleSummary): string {
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
return schedule.target.config.model;
}
if (serverName && !singleHost) {
parts.unshift(serverName);
return "Default model";
}
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
return status === "active" ? "success" : "muted";
}
function statusLabel(status: ScheduleSummary["status"]): string {
if (status === "active") {
return "Active";
}
return parts.join(" · ");
if (status === "paused") {
return "Paused";
}
return "Completed";
}
function nextRunLabel(schedule: ScheduleSummary): string {
if (schedule.status === "paused") {
return "Paused";
}
if (schedule.status === "completed") {
return "Completed";
}
return formatNextRun(schedule.nextRunAt) || "—";
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
@@ -134,11 +113,6 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
@@ -152,10 +126,15 @@ export function ScheduleRow({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const provider = resolveProvider(schedule);
const title = resolveScheduleTitle(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const meta = [
resolveModelLabel(schedule),
formatCadence(schedule.cadence),
nextRunLabel(schedule),
]
.filter(Boolean)
.join(" · ");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -189,9 +168,6 @@ export function ScheduleRow({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
@@ -199,10 +175,12 @@ export function ScheduleRow({
</View>
<View style={styles.trailing}>
<StatusBadge label={badge.label} variant={badge.variant} />
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
@@ -233,19 +211,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -268,7 +240,6 @@ function ScheduleKebabMenu({
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
@@ -279,7 +250,7 @@ function ScheduleKebabMenu({
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
disabled={schedule.status === "completed"}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
@@ -290,7 +261,6 @@ function ScheduleKebabMenu({
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
@@ -354,11 +324,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
@@ -3,48 +3,44 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
schedule: AggregatedSchedule;
targetLabel: string;
provider: string | null;
state: ScheduleDerivedState;
serverName: string;
/** True when only one host exists, so the host name is redundant in rows. */
singleHost: boolean;
}
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
interface SchedulesTableProps {
rows: ScheduleRowView[];
serverId: string;
schedules: ScheduleSummary[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the screen's "New schedule" button), so the table delegates edit
* shares the header's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: AggregatedSchedule) => void;
onEditSchedule: (schedule: ScheduleSummary) => void;
}
/**
* The schedules list: a single settings-style card of rows across every
* connected host, in a full-width list matching the History screen. Rows own
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
* a destructive confirm) and delegate editing upward.
* The schedules list: a single settings-style card of rows in a centered,
* width-constrained reading column, matching the projects list. Owns row-level
* actions (pause/resume/run/delete via the mutations hook + a destructive
* confirm for delete) and delegates editing to the parent.
*/
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
export function SchedulesTable({
serverId,
schedules,
onEditSchedule,
}: SchedulesTableProps): ReactElement {
const mutations = useScheduleMutations({ serverId });
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{rows.map((row, index) => (
{schedules.map((schedule, index) => (
<SchedulesTableRow
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
key={schedule.id}
schedule={schedule}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
@@ -54,26 +50,28 @@ export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): R
}
// ---------------------------------------------------------------------------
// Per-row wrapper owns local in-flight state and binds mutations to this
// schedule's host. Local state keeps pending precise to the acting row even
// when several rows are acted on at once (the mutations hook exposes only a
// single global pending flag per action).
// Per-row wrapper owns local in-flight state and binds the table's mutation
// callbacks to this schedule. Local state keeps pending precise to the acting
// row even when several rows are acted on at once (the mutations hook exposes
// only a single global pending flag per action).
// ---------------------------------------------------------------------------
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
const NO_PENDING: ScheduleRowPending = {};
function SchedulesTableRow({
row,
schedule,
isFirst,
mutations,
onEditSchedule,
}: {
row: ScheduleRowView;
schedule: ScheduleSummary;
isFirst: boolean;
onEditSchedule: (schedule: AggregatedSchedule) => void;
mutations: ScheduleMutations;
onEditSchedule: (schedule: ScheduleSummary) => void;
}): ReactElement {
const { schedule } = row;
const { id, serverId } = schedule;
const mutations = useScheduleMutations({ serverId });
const { id } = schedule;
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
@@ -129,11 +127,6 @@ function SchedulesTableRow({
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}
@@ -145,9 +138,14 @@ function SchedulesTableRow({
);
}
const CONTENT_MAX_WIDTH = 720;
const styles = StyleSheet.create((theme) => ({
// Full-width list padding matching the History screen.
// Center the card in a readable column, matching settings and projects.
listContent: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
width: "100%",
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
},
}));
@@ -56,7 +56,6 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
@@ -1882,7 +1881,6 @@ function ProjectBlock({
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -1904,16 +1902,14 @@ function ProjectBlock({
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, project],
);
const active = isProjectSelectedByRoute({
@@ -2066,7 +2062,7 @@ function ProjectBlock({
containerStyle={styles.workspaceListContainer}
/>
);
} else if (rowModel.trailingAction.kind === "new_workspace") {
} else if (rowModel.trailingAction.kind === "new_worktree") {
projectChildren = (
<NewWorkspaceGhostRow
project={project}
@@ -2089,7 +2085,7 @@ function ProjectBlock({
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
worktreeTarget={
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
@@ -2110,7 +2106,6 @@ function ProjectBlock({
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
// oxlint-disable-next-line complexity
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
@@ -2122,7 +2117,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
@@ -2189,8 +2183,6 @@ export function SidebarWorkspaceList({
}
return labels;
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
const content =
@@ -2216,7 +2208,6 @@ export function SidebarWorkspaceList({
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
@@ -2265,7 +2256,6 @@ function ProjectModeList({
pathname,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
@@ -2273,7 +2263,6 @@ function ProjectModeList({
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2462,7 +2451,6 @@ function ProjectModeList({
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
},
@@ -2473,7 +2461,6 @@ function ProjectModeList({
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
+1 -1
View File
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
@@ -11,6 +11,7 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
intent: attachment.attachment.intent ?? null,
comment: attachment.attachment.comment ?? null,
});
}
+18 -18
View File
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.32.0",
version: "0.31.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.115.0",
version: "2.114.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.115.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.17",
version: "0.2.12",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.17", "acp"],
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.13",
version: "0.4.12",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.164.0",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.3",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.24",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.36",
version: "1.0.33",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.36", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.5",
version: "0.19.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.5", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
+1
View File
@@ -132,6 +132,7 @@ export interface DesktopBrowserBridge {
workspaceId: string;
browserId: string | null;
}) => Promise<void>;
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
executeAutomationCommand?: (
@@ -0,0 +1,210 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
PaseoAgentCatalogEntry,
PaseoAgentOAuthCompleteResponse,
PaseoAgentOAuthStartResponse,
PaseoAgentRenameProviderRequest,
PaseoAgentSetProviderRequest,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
export function paseoAgentProvidersQueryKey(serverId: string | null) {
return ["paseo-agent-providers", serverId] as const;
}
export function paseoAgentCatalogQueryKey(serverId: string | null) {
return ["paseo-agent-catalog", serverId] as const;
}
function describeQueryError(error: unknown): string | null {
if (!error) {
return null;
}
return error instanceof Error ? error.message : String(error);
}
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
export type PaseoAgentRenameProviderInput = Omit<
PaseoAgentRenameProviderRequest,
"type" | "requestId"
>;
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
interface UsePaseoAgentProvidersResult {
supported: boolean;
catalogSupported: boolean;
providers: RedactedPaseoAgentProviderConfig[];
catalog: PaseoAgentCatalogEntry[];
defaultModel: string | null;
isLoading: boolean;
isCatalogLoading: boolean;
error: string | null;
catalogError: string | null;
refresh: () => Promise<void>;
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
renameProvider: (
input: PaseoAgentRenameProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
const supported = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
);
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
const catalogSupported = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
);
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
const query = useQuery({
queryKey,
enabled: Boolean(supported && serverId && client && isConnected),
staleTime: 30_000,
queryFn: async () => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
return client.getPaseoAgentProviders();
},
});
const catalogQuery = useQuery({
queryKey: catalogQueryKey,
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
staleTime: 30_000,
queryFn: async () => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
return client.getPaseoAgentCatalog();
},
});
const refresh = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey });
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
}, [catalogQueryKey, queryClient, queryKey]);
const error = query.data?.error ?? describeQueryError(query.error);
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
const setProviderMutation = useMutation({
mutationFn: async (input: PaseoAgentSetProviderInput) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.setPaseoAgentProvider(input);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result.provider;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: setProviderAsync } = setProviderMutation;
const setProvider = useCallback(
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
[setProviderAsync],
);
const renameProviderMutation = useMutation({
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.renamePaseoAgentProvider(input);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result.provider;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
const renameProvider = useCallback(
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
[renameProviderAsync],
);
const startOAuthMutation = useMutation({
mutationFn: async (input: { name: string; mode?: string }) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result;
},
});
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
const startOAuth = useCallback(
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
[startOAuthAsync],
);
const completeOAuthMutation = useMutation({
mutationFn: async (name: string) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.completePaseoAgentOAuth(name);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
const completeOAuth = useCallback(
(name: string) => completeOAuthAsync(name),
[completeOAuthAsync],
);
return {
supported,
catalogSupported,
providers: query.data?.providers ?? [],
catalog: catalogQuery.data?.catalog ?? [],
defaultModel: query.data?.defaultModel ?? null,
isLoading: query.isLoading,
isCatalogLoading: catalogQuery.isLoading,
error,
catalogError,
refresh,
setProvider,
renameProvider,
startOAuth,
completeOAuth,
};
}
@@ -13,10 +13,7 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -63,9 +60,11 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
}
}
function updateSchedulesData(
function updateScheduleSections(
queryClient: QueryClient,
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
@@ -73,7 +72,7 @@ function updateSchedulesData(
if (!current) {
return current;
}
return { ...current, schedules: updateSchedules(current.schedules) };
return { sections: current.sections.map(updateSection) };
},
);
}
@@ -85,18 +84,26 @@ function optimisticallySetStatus(
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateSchedulesData(queryClient, (schedules) =>
schedules.map((schedule) =>
schedule.serverId === serverId && schedule.id === id
? { ...schedule, status, pausedAt }
: schedule,
),
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.map((schedule) =>
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
),
}
: section,
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateSchedulesData(queryClient, (schedules) =>
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
);
}
+15 -20
View File
@@ -1,30 +1,23 @@
import { useMemo, useSyncExternalStore } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
type ScheduleHostSection,
} from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
// Cache identity for the host set. The query also carries the runtime version
// (below) so it retries as connectivity changes and reliably fetches once a host
// comes online — even on a cold deep-link. The full-screen spinner flash that
// keying on the version used to cause is prevented by keepPreviousData plus the
// isInitialLoad(data === undefined) gate, not by dropping the version.
export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
}
export interface UseSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
@@ -40,21 +33,23 @@ export function useSchedules(): UseSchedulesResult {
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
});
return {
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
sections: query.data?.sections ?? [],
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: () => {
+12 -7
View File
@@ -422,17 +422,22 @@ export const ar: TranslationResources = {
enterUrl: "أدخل URL",
openDevTools: "افتح أدوات تطوير المتصفح",
cancelSelector: "إلغاء محدد العنصر",
annotateElement: "التعليق على العنصر",
screenshotElement: "لقطة للعنصر",
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
elementCopied: "تم نسخ العنصر إلى الحافظة",
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
selectElement: "حدد العنصر",
grabElement: "نسخ العنصر إلى الحافظة",
grabElementLabel: "العنصر",
grabFailed: "تعذّر نسخ العنصر",
},
annotate: {
title: "التعليق على العنصر",
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
title: "إرسال ملاحظات إلى الوكيل",
placeholder: "صف ما الذي ينبغي تغييره…",
submit: "إرفاق",
cancel: "إلغاء",
intents: {
fix: "إصلاح",
change: "تغيير",
question: "سؤال",
approve: "موافقة",
},
},
devices: {
label: "حجم الجهاز",
+12 -7
View File
@@ -422,17 +422,22 @@ export const en = {
enterUrl: "Enter URL",
openDevTools: "Open browser dev tools",
cancelSelector: "Cancel element selector",
annotateElement: "Annotate element",
screenshotElement: "Screenshot element",
screenshotCopied: "Copied screenshot to clipboard",
elementCopied: "Copied element to clipboard",
screenshotFailed: "Couldn't copy screenshot",
selectElement: "Select element",
grabElement: "Copy element to clipboard",
grabElementLabel: "element",
grabFailed: "Couldn't copy element",
},
annotate: {
title: "Annotate element",
placeholder: "Message to the agent about this element…",
title: "Send feedback to agent",
placeholder: "Describe what should change…",
submit: "Attach",
cancel: "Cancel",
intents: {
fix: "Fix",
change: "Change",
question: "Question",
approve: "Approve",
},
},
devices: {
label: "Device size",
+12 -7
View File
@@ -426,17 +426,22 @@ export const es: TranslationResources = {
enterUrl: "IngreseURL",
openDevTools: "Abrir herramientas de desarrollo del navegador",
cancelSelector: "Cancelar selector de elementos",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada al portapapeles",
elementCopied: "Elemento copiado al portapapeles",
screenshotFailed: "No se pudo copiar la captura",
selectElement: "Seleccionar elemento",
grabElement: "Copiar elemento al portapapeles",
grabElementLabel: "elemento",
grabFailed: "No se pudo copiar el elemento",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensaje al agente sobre este elemento…",
title: "Enviar comentarios al agente",
placeholder: "Describe qué debería cambiar…",
submit: "Adjuntar",
cancel: "Cancelar",
intents: {
fix: "Corregir",
change: "Cambiar",
question: "Pregunta",
approve: "Aprobar",
},
},
devices: {
label: "Tamaño del dispositivo",
+12 -7
View File
@@ -426,17 +426,22 @@ export const fr: TranslationResources = {
enterUrl: "EntrezURL",
openDevTools: "Outils de développement du navigateur ouvert",
cancelSelector: "Annuler le sélecteur d'élément",
annotateElement: "Annoter l'élément",
screenshotElement: "Capturer l'élément",
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
elementCopied: "Élément copié dans le presse-papiers",
screenshotFailed: "Impossible de copier la capture",
selectElement: "Sélectionner un élément",
grabElement: "Copier l'élément dans le presse-papiers",
grabElementLabel: "élément",
grabFailed: "Impossible de copier l'élément",
},
annotate: {
title: "Annoter l'élément",
placeholder: "Message à l'agent concernant cet élément…",
title: "Envoyer un retour à l'agent",
placeholder: "Décrivez ce qui doit changer…",
submit: "Joindre",
cancel: "Annuler",
intents: {
fix: "Corriger",
change: "Modifier",
question: "Question",
approve: "Approuver",
},
},
devices: {
label: "Taille de l'appareil",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ja: TranslationResources = {
enterUrl: "URLを入力",
openDevTools: "ブラウザ開発ツールを開く",
cancelSelector: "要素セレクターをキャンセル",
annotateElement: "要素に注釈を付ける",
screenshotElement: "要素のスクリーンショット",
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
elementCopied: "要素をクリップボードにコピーしました",
screenshotFailed: "スクリーンショットをコピーできませんでした",
selectElement: "要素を選択",
grabElement: "要素をクリップボードにコピー",
grabElementLabel: "要素",
grabFailed: "要素をコピーできませんでした",
},
annotate: {
title: "要素に注釈を付ける",
placeholder: "この要素についてエージェントへのメッセージ…",
title: "エージェントにフィードバックを送信",
placeholder: "変更すべき内容を記述…",
submit: "添付",
cancel: "キャンセル",
intents: {
fix: "修正",
change: "変更",
question: "質問",
approve: "承認",
},
},
devices: {
label: "デバイスサイズ",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ptBR: TranslationResources = {
enterUrl: "Inserir URL",
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
cancelSelector: "Cancelar seletor de elemento",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada para a área de transferência",
elementCopied: "Elemento copiado para a área de transferência",
screenshotFailed: "Não foi possível copiar a captura",
selectElement: "Selecionar elemento",
grabElement: "Copiar elemento para a área de transferência",
grabElementLabel: "elemento",
grabFailed: "Não foi possível copiar o elemento",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensagem ao agente sobre este elemento…",
title: "Enviar feedback ao agente",
placeholder: "Descreva o que deve mudar…",
submit: "Anexar",
cancel: "Cancelar",
intents: {
fix: "Corrigir",
change: "Alterar",
question: "Pergunta",
approve: "Aprovar",
},
},
devices: {
label: "Tamanho do dispositivo",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ru: TranslationResources = {
enterUrl: "Введите URL",
openDevTools: "Открыть инструменты разработки браузера",
cancelSelector: "Отменить выбор элемента",
annotateElement: "Аннотировать элемент",
screenshotElement: "Снимок элемента",
screenshotCopied: "Снимок скопирован в буфер обмена",
elementCopied: "Элемент скопирован в буфер обмена",
screenshotFailed: "Не удалось скопировать снимок",
selectElement: "Выберите элемент",
grabElement: "Скопировать элемент в буфер обмена",
grabElementLabel: "элемент",
grabFailed: "Не удалось скопировать элемент",
},
annotate: {
title: "Аннотировать элемент",
placeholder: "Сообщение агенту об этом элементе…",
title: "Отправить отзыв агенту",
placeholder: "Опишите, что нужно изменить…",
submit: "Прикрепить",
cancel: "Отмена",
intents: {
fix: "Исправить",
change: "Изменить",
question: "Вопрос",
approve: "Одобрить",
},
},
devices: {
label: "Размер устройства",
+12 -7
View File
@@ -422,17 +422,22 @@ export const zhCN: TranslationResources = {
enterUrl: "输入 URL",
openDevTools: "打开浏览器开发者工具",
cancelSelector: "取消元素选择器",
annotateElement: "标注元素",
screenshotElement: "截图元素",
screenshotCopied: "已将截图复制到剪贴板",
elementCopied: "已将元素复制到剪贴板",
screenshotFailed: "无法复制截图",
selectElement: "选择元素",
grabElement: "复制元素到剪贴板",
grabElementLabel: "元素",
grabFailed: "复制元素失败",
},
annotate: {
title: "标注元素",
placeholder: "给智能体关于此元素的留言…",
title: "发送反馈给智能体",
placeholder: "描述需要修改的内容…",
submit: "附加",
cancel: "取消",
intents: {
fix: "修复",
change: "修改",
question: "提问",
approve: "认可",
},
},
devices: {
label: "设备尺寸",
@@ -1,9 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { isNewAgentSchedule } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
serverId: string;
serverName: string;
@@ -18,76 +17,65 @@ export interface ScheduleRuntime {
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
}
/** A schedule tagged with the host it came from, so the flat list can render a
* per-row host label and scope mutations without host sections. */
export interface AggregatedSchedule extends ScheduleSummary {
export interface ScheduleHostSection {
serverId: string;
serverName: string;
}
export interface ScheduleHostError {
serverId: string;
serverName: string;
message: string;
}
export interface FetchAggregatedSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isOnline: boolean;
schedules: ScheduleSummary[];
error: string | null;
}
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
hosts: ScheduleHostInput[];
runtime: ScheduleRuntime;
}
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
* every connected host fails do we throw so the screen shows a full error.
*/
export interface FetchAggregatedSchedulesResult {
sections: ScheduleHostSection[];
}
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
await Promise.all(
input.hosts.map(async (host) => {
const sections = await Promise.all(
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return;
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: null,
};
}
connectedAttempts += 1;
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
for (const schedule of payload.schedules) {
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
}
} catch (error) {
hostErrors.push({
return {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
});
isOnline,
schedules: payload.schedules.filter(isNewAgentSchedule),
error: null,
};
} catch (error) {
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: toErrorMessage(error),
};
}
}),
);
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
return { sections };
}
@@ -1,133 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
return {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
...overrides,
};
}
function resolve(
schedule: ScheduleSummary,
options?: {
agents?: Array<[string, ScheduleTargetAgent]>;
projects?: Array<[string, string]>;
agentDataLoaded?: boolean;
},
) {
return resolveSchedule({
schedule,
serverId: "host-1",
now: NOW,
agentsByKey: new Map(options?.agents ?? []),
projectNameByCwd: new Map(options?.projects ?? []),
agentDataLoaded: options?.agentDataLoaded ?? true,
});
}
describe("resolveSchedule state", () => {
it("keeps active and paused schedules runnable", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
expect(scheduleBucket("active")).toBe("runnable");
expect(scheduleBucket("paused")).toBe("runnable");
});
it("treats a past expiresAt as expired regardless of status", () => {
const result = resolve(
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
);
expect(result.state).toBe("expired");
expect(result.bucket).toBe("ended");
});
it("ignores an unparseable expiresAt", () => {
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
});
it("derives finished only from completed-and-not-expired", () => {
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
expect(
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
).toBe("expired");
});
it("marks an agent target gone when the client has no such agent", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).state).toBe("targetGone");
expect(resolve(schedule).bucket).toBe("ended");
});
it("does not claim gone before the agent directory has loaded", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
});
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
const paused = makeSchedule({
status: "paused",
target: { type: "agent", agentId: AGENT_ID },
});
expect(resolve(paused).state).toBe("targetGone");
});
it("never claims a new-agent cwd is gone", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
});
});
describe("resolveSchedule target line", () => {
it("names an agent target by its client title and provider", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
});
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
expect(result.state).toBe("active");
});
it("falls back to Untitled agent when the agent has no title", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
});
expect(result.target.label).toBe("Untitled agent");
});
it("labels a gone agent target as unavailable with no glyph", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
});
it("names a new-agent cwd by matched project, else the shortened path", () => {
const matched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
});
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
label: "My Project",
provider: "codex",
});
const unmatched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
});
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
});
});
@@ -1,109 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
// Derived from existing fields only — no new protocol state. "active"/"paused"
// mirror the stored status; the rest are computed truths the daemon does not
// spell out in a single field.
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
export type ScheduleBucket = "runnable" | "ended";
export interface ScheduleTargetAgent {
title: string | null;
provider: string | null;
}
export interface ScheduleTargetResolution {
/** The target line: agent title, project name, or the shortened cwd. */
label: string;
/** Provider glyph for the row, when known. */
provider: string | null;
}
export interface ResolvedSchedule {
state: ScheduleDerivedState;
bucket: ScheduleBucket;
target: ScheduleTargetResolution;
}
export interface ResolveScheduleInput {
schedule: ScheduleSummary;
serverId: string;
now: number;
/** Client agent directory keyed by `${serverId}:${agentId}`. */
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
/** Known project roots keyed by `${serverId}:${cwd}`. */
projectNameByCwd: ReadonlyMap<string, string>;
/**
* Whether the agent directory has finished its first load. While false we do
* not claim an agent target is gone — absence would just be a cold cache.
*/
agentDataLoaded: boolean;
}
function agentKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
function isExpired(schedule: ScheduleSummary, now: number): boolean {
if (!schedule.expiresAt) {
return false;
}
const expiresAt = Date.parse(schedule.expiresAt);
return Number.isFinite(expiresAt) && expiresAt <= now;
}
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
if (schedule.target.type !== "agent" || !agentDataLoaded) {
return false;
}
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
}
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
if (schedule.target.type === "agent") {
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
if (agent) {
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
}
return { label: "Agent unavailable", provider: null };
}
return {
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
provider: schedule.target.config.provider,
};
}
// One badge, one truth. Order matters: expiry and a missing target are more
// informative than the raw "completed"/"paused" status, so they win.
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
const { schedule, now } = input;
if (isExpired(schedule, now)) {
return "expired";
}
if (isAgentTargetGone(input)) {
return "targetGone";
}
if (schedule.status === "completed") {
return "finished";
}
if (schedule.status === "paused") {
return "paused";
}
return "active";
}
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
return state === "active" || state === "paused" ? "runnable" : "ended";
}
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
const state = deriveState(input);
return {
state,
bucket: scheduleBucket(state),
target: resolveTarget(input),
};
}
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProjectSummary } from "@/utils/projects";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
describeScheduleCwd,
} from "./schedule-project-targets";
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
projectKey: "proj",
projectName: "Project",
hosts: [],
totalWorkspaceCount: 0,
hostCount: 0,
onlineHostCount: 0,
...overrides,
};
}
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
workspaceCount: 0,
workspaces: [],
...overrides,
};
}
describe("buildScheduleProjectTargets", () => {
it("emits one target per online host with a repo root", () => {
const targets = buildScheduleProjectTargets([
makeProject({
projectName: "Alpha",
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
}),
]);
expect(targets).toHaveLength(2);
expect(targets[0]).toMatchObject({
serverId: "host-1",
cwd: "/tmp/alpha",
projectName: "Alpha",
});
});
it("skips offline hosts and blank repo roots", () => {
const targets = buildScheduleProjectTargets([
makeProject({
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
}),
]);
expect(targets).toHaveLength(0);
});
});
describe("describeScheduleCwd", () => {
it("prefers a matched project name and shortens unmatched paths", () => {
const byCwd = buildProjectNameByCwd(
buildScheduleProjectTargets([
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
]),
);
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
).toBe("Alpha");
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
).toBe("~/api");
});
});
@@ -1,75 +0,0 @@
import type { ProjectSummary } from "@/utils/projects";
import { shortenPath } from "@/utils/shorten-path";
export const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
export function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
/**
* The project roots the schedule form can target: one per online host of each
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
*/
export function buildScheduleProjectTargets(
projects: readonly ProjectSummary[],
): ScheduleProjectTarget[] {
const targets: ScheduleProjectTarget[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
targets.push({
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
});
}
}
return targets;
}
function projectNameKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd.trim()}`;
}
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
export function buildProjectNameByCwd(
targets: readonly ScheduleProjectTarget[],
): Map<string, string> {
const byCwd = new Map<string, string>();
for (const target of targets) {
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
}
return byCwd;
}
/**
* Name a stored cwd for display: the matching project name when the client
* knows this root on this host, otherwise the shortened path itself. Never
* blank, never a claim the client cannot back up.
*/
export function describeScheduleCwd(input: {
serverId: string;
cwd: string;
projectNameByCwd: ReadonlyMap<string, string>;
}): string {
return (
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
);
}
+115 -262
View File
@@ -1,40 +1,14 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactElement,
} from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { ScrollView, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { Plus } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { MenuHeader } from "@/components/headers/menu-header";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
import { SchedulesTable } from "@/components/schedules/schedules-table";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
useSchedules,
type AggregatedSchedule,
type ScheduleHostError,
} from "@/hooks/use-schedules";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
resolveSchedule,
type ScheduleBucket,
type ScheduleTargetAgent,
} from "@/schedules/schedule-derivation";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
} from "@/schedules/schedule-project-targets";
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
type FormState =
@@ -42,11 +16,6 @@ type FormState =
| { mode: "create" }
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
];
export function SchedulesScreen(): ReactElement {
const isFocused = useIsFocused();
@@ -58,120 +27,38 @@ export function SchedulesScreen(): ReactElement {
}
function SchedulesScreenContent(): ReactElement {
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
const { agents } = useAggregatedAgents({ includeArchived: true });
const { projects } = useProjects();
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// Per-host agent-directory readiness from the runtime, not the aggregate agent
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
// agents, so a still-loading host would falsely mark its agent-target
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
// host's directory has loaded at least once.
const agentDirReadyHosts = useMemo(() => {
void runtimeVersion;
const ready = new Set<string>();
for (const host of hosts) {
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
ready.add(host.serverId);
}
}
return ready;
}, [hosts, runtime, runtimeVersion]);
const { sections, isLoading, isError, error, refetch } = useSchedules();
const [form, setForm] = useState<FormState>({ mode: "closed" });
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
const openEdit = useCallback((schedule: AggregatedSchedule) => {
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
const openCreate = useCallback(() => {
setForm({ mode: "create" });
}, []);
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
const agentsByKey = useMemo(() => {
const map = new Map<string, ScheduleTargetAgent>();
for (const agent of agents) {
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
}
return map;
}, [agents]);
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
setForm({ mode: "edit", serverId, schedule });
}, []);
const projectNameByCwd = useMemo(
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
[projects],
const closeForm = useCallback(() => {
setForm({ mode: "closed" });
}, []);
const headerAction = useMemo(
() => (
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
New schedule
</Button>
),
[openCreate],
);
// Resolve every schedule's derived state and target line once, then partition
// by the host and status filters. Sorted newest-first for a stable order
// across hosts.
const resolvedRows = useMemo(() => {
const now = Date.now();
return schedules.map((schedule) => ({
schedule,
resolved: resolveSchedule({
schedule,
serverId: schedule.serverId,
now,
agentsByKey,
projectNameByCwd,
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
}),
}));
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
const visibleRows = useMemo<ScheduleRowView[]>(() => {
const singleHost = hosts.length <= 1;
return resolvedRows
.filter(
({ schedule, resolved }) =>
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
resolved.bucket === statusFilter,
)
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
.map(({ schedule, resolved }) => ({
schedule,
targetLabel: resolved.target.label,
provider: resolved.target.provider,
state: resolved.state,
serverName: schedule.serverName,
singleHost,
}));
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
const showLoadError = isError && schedules.length === 0;
const showHostFilter = hosts.length > 1;
return (
<View style={styles.container}>
<MenuHeader title="Schedules" />
<SchedulesScreenBody
rows={visibleRows}
hostErrors={hostErrors}
hasSchedules={schedules.length > 0}
isInitialLoad={isInitialLoad}
showLoadError={showLoadError}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
showHostFilter={showHostFilter}
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
<MenuHeader title="Schedules" rightContent={headerAction} />
<SchedulesBody
sections={sections}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
onCreate={openCreate}
onEdit={openEdit}
@@ -187,38 +74,24 @@ function SchedulesScreenContent(): ReactElement {
);
}
function SchedulesScreenBody({
rows,
hostErrors,
hasSchedules,
isInitialLoad,
showLoadError,
statusFilter,
onStatusFilterChange,
showHostFilter,
hosts,
selectedHost,
onSelectHost,
function SchedulesBody({
sections,
isLoading,
isError,
error,
onRetry,
onCreate,
onEdit,
}: {
rows: ScheduleRowView[];
hostErrors: ScheduleHostError[];
hasSchedules: boolean;
isInitialLoad: boolean;
showLoadError: boolean;
statusFilter: ScheduleBucket;
onStatusFilterChange: (value: ScheduleBucket) => void;
showHostFilter: boolean;
hosts: ReturnType<typeof useHosts>;
selectedHost: string;
onSelectHost: (serverId: string) => void;
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
onRetry: () => void;
onCreate: () => void;
onEdit: (schedule: AggregatedSchedule) => void;
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
}): ReactElement {
if (isInitialLoad) {
if (isLoading) {
return (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
@@ -226,85 +99,76 @@ function SchedulesScreenBody({
);
}
if (showLoadError) {
if (isError) {
return (
<View style={styles.centered}>
<Text style={styles.message}>Unable to load schedules</Text>
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
Try again
Retry
</Button>
</View>
);
}
if (!hasSchedules) {
if (sections.length === 0) {
return (
<View style={styles.centered} testID="schedules-empty">
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<View style={styles.centered}>
<Text style={styles.message}>No schedules yet</Text>
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
</View>
);
}
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
return (
<View style={styles.body}>
<View style={styles.filterRow}>
<View style={styles.filterRowControls}>
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={onSelectHost}
triggerTestID="schedules-host-filter-trigger"
/>
) : null}
<SegmentedControl
size="sm"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={STATUS_FILTER_OPTIONS}
testID="schedules-status-filter"
/>
</View>
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
New schedule
</Button>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
</ScrollView>
</View>
);
}
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-sections"
>
{sections.map((section) => (
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
))}
</ScrollView>
);
}
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
function ScheduleHostSectionView({
section,
onEdit,
}: {
section: ScheduleHostSection;
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
}): ReactElement {
const handleEdit = useCallback(
(schedule: ScheduleSummary) => {
onEdit(section.serverId, schedule);
},
[onEdit, section.serverId],
);
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="schedules-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`${error.serverName}: Could not load schedules`}
</Text>
))}
</View>
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
{section.serverName}
</Text>
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
{section.schedules.length > 0 ? (
<SchedulesTable
serverId={section.serverId}
schedules={section.schedules}
onEditSchedule={handleEdit}
/>
) : null}
{section.schedules.length === 0 && !section.error ? (
<View style={styles.sectionEmpty}>
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
</View>
) : null}
</View>
);
}
@@ -314,10 +178,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
minHeight: 0,
},
centered: {
flex: 1,
justifyContent: "center",
@@ -325,50 +185,43 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[6],
padding: theme.spacing[6],
},
filterRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingTop: theme.spacing[4],
},
filterRowControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
flexShrink: 1,
flexWrap: "wrap",
},
scroll: {
flex: 1,
minHeight: 0,
},
scrollContent: {
gap: theme.spacing[3],
gap: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
errorsBannerWrap: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
section: {
gap: theme.spacing[2],
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
sectionTitle: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
sectionError: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
filterEmpty: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingVertical: theme.spacing[6],
alignItems: "center",
sectionEmpty: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
filterEmptyText: {
sectionEmptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
+102 -7
View File
@@ -1,18 +1,23 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen() {
@@ -25,6 +30,71 @@ export function SessionsScreen() {
return <SessionsScreenContent />;
}
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -82,11 +152,10 @@ function SessionsScreenContent() {
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<HostFilter
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
</View>
) : null}
@@ -138,6 +207,32 @@ const styles = StyleSheet.create((theme) => ({
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
@@ -1,5 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
@@ -23,14 +22,10 @@ interface BrowserStoreState extends BrowserIndexState {
}
function createBrowserId(): string {
let browserId: string;
if (typeof globalThis.crypto?.randomUUID === "function") {
browserId = globalThis.crypto.randomUUID();
} else {
const randomSuffix = Math.random().toString(16).slice(2) || "0";
browserId = `${Date.now()}-${randomSuffix}`;
return globalThis.crypto.randomUUID();
}
return BrowserAutomationBrowserIdSchema.parse(browserId);
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -79,49 +79,13 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
});
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", true]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({ kind: "none" });
});
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "git" }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("targets the project host, not route state, for new workspace actions", () => {
it("targets the project host, not route state, for new worktree actions", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
@@ -134,34 +98,13 @@ describe("buildSidebarProjectRowModel", () => {
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("targets the first multiplicity-capable host for a non-git project", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
],
}),
collapsed: false,
supportsMultiplicityByServerId: new Map([["host-b", true]]),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -177,7 +120,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -206,7 +149,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "collapse",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
}
export type SidebarProjectTrailingAction =
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
| { kind: "none" };
export interface SidebarProjectSectionRowModel {
@@ -17,8 +17,6 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
iconWorkingDir: string;
@@ -42,18 +40,9 @@ export function resolveSidebarProjectIconTarget(
return null;
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
// directories add a second workspace. Mirrors the gate used by the global "New
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectHostTarget | null {
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
for (const host of project.hosts) {
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
if (!host.canCreateWorktree) {
continue;
}
const target = hostTarget(host);
@@ -64,25 +53,18 @@ function resolveNewWorkspaceTarget(
return null;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
return target ? { kind: "new_workspace", target } : { kind: "none" };
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
const target = resolveNewWorktreeTarget(project);
return target ? { kind: "new_worktree", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
kind: "project_section",
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
),
trailingAction: projectTrailingAction(input.project),
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.104-beta.2",
"version": "0.1.103",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/server": "0.1.104-beta.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+7 -8
View File
@@ -33,10 +33,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
try {
const serverInfo = await client.waitForServerInfo(PAIRING_DAEMON_RPC_TIMEOUT_MS);
if (serverInfo.features?.daemonStatusRpc === true) {
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
@@ -46,11 +45,11 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
options,
);
return;
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
await client.close().catch(() => {});
}
+4 -5
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -156,11 +157,9 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter(
(provider) => provider.voice?.enabled === true && provider.defaultModeId !== null,
).map((provider) => ({ label: provider.label, binary: provider.id }));
async function checkProviderBinary(
binary: string,
+1 -4
View File
@@ -50,10 +50,7 @@ export function addLoopRunOptions(command: Command): Command {
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode for the worker agent")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
@@ -0,0 +1,710 @@
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { runAddCommand } from "./add.js";
interface CatalogEntry {
id: string;
label: string;
api: string;
baseUrl: string;
auth: Record<string, unknown>;
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
}
interface RecordingClientInput {
catalog: CatalogEntry[];
features?: Record<string, unknown>;
setProvider?: (input: {
name: string;
providerType: string;
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => Promise<unknown>;
startOAuth?: (
name: string,
options?: { mode?: string; requestId?: string } | string,
) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
}
function createClient(input: RecordingClientInput) {
return {
waitForServerInfo: async () => ({
status: "server_info",
serverId: "test-daemon",
features: input.features ?? { paseoAgentCatalog: true },
}),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: input.catalog,
error: null,
}),
setPaseoAgentProvider: async (providerInput: {
name: string;
providerType: string;
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => {
if (input.setProvider) {
return input.setProvider(providerInput);
}
return {
requestId: "set-1",
success: true,
provider: {
name: providerInput.name,
providerType: providerInput.providerType,
models: providerInput.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
startPaseoAgentOAuth: async (
name: string,
options?: { mode?: string; requestId?: string } | string,
) =>
input.startOAuth?.(name, options) ?? {
requestId: "oauth-start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
},
error: null,
},
completePaseoAgentOAuth: async (name: string) =>
input.completeOAuth?.(name) ?? {
requestId: "oauth-complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
},
storePaseoAgentOAuthCredential: async (credentialInput: {
name: string;
credential: unknown;
}) =>
input.storeCredential?.(credentialInput) ?? {
requestId: "oauth-store-1",
success: true,
name: credentialInput.name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
},
close: async () => {},
};
}
function apiKeyEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
return {
id: "alpha-key",
label: "Alpha Key",
api: "test-api",
baseUrl: "https://alpha.example.test",
auth: {
kind: "api_key",
envVar: "ALPHA_API_KEY",
hint: "Create an Alpha key before continuing.",
keyUrl: "https://alpha.example.test/keys",
placeholder: "Alpha API key",
},
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
...overrides,
};
}
function oauthEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
return {
id: "beta-oauth",
label: "Beta OAuth",
api: "test-oauth-api",
baseUrl: "https://beta.example.test",
auth: { kind: "oauth", flow: "beta-flow" },
models: [{ id: "beta-model" }],
...overrides,
};
}
describe("provider add", () => {
it("configures an API-key provider from a hidden prompt without echoing the key", async () => {
const setCalls: unknown[] = [];
const prompts: string[] = [];
const output: string[] = [];
const result = await runAddCommand(
"alpha-key",
{ host: "localhost:7777", name: "alpha-main" },
{} as never,
{
write: (message) => output.push(message),
promptSecret: async (message) => {
prompts.push(message);
return "redaction-sentinel";
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
});
},
},
);
expect(setCalls).toEqual([
{
name: "alpha-main",
providerType: "alpha-key",
options: {
apiKey: "redaction-sentinel",
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
},
},
]);
expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]);
expect(output.join("\n")).toContain("Create an Alpha key");
expect(output.join("\n")).toContain("https://alpha.example.test/keys");
expect(render(result, { format: "json" })).not.toContain("redaction-sentinel");
expect(render(result, { format: "table", noColor: true })).toContain("alpha-main");
});
it("stores an environment reference when the API-key prompt is empty", async () => {
const setCalls: unknown[] = [];
const result = await runAddCommand("alpha-key", {}, {} as never, {
promptSecret: async () => "",
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: false, source: "env" },
available: false,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "$ALPHA_API_KEY",
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
},
},
]);
expect(result.data.auth).toBe("Needs attention");
});
it("reads an API key from stdin for scripts", async () => {
const setCalls: unknown[] = [];
await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, {
readStdin: async () => "stdin-secret\n",
promptSecret: async () => {
throw new Error("prompt should not be used with --api-key-stdin");
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ models: [] })],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "stdin-secret",
models: [{ id: "one" }, { id: "two" }],
},
},
]);
});
it("configures an API-key provider without model defaults", async () => {
const setCalls: unknown[] = [];
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
readStdin: async () => "stdin-secret\n",
promptSecret: async () => {
throw new Error("prompt should not be used with --api-key-stdin");
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ models: [] })],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "stdin-secret",
},
},
]);
expect(result.data).toMatchObject({
name: "alpha-key",
auth: "Connected",
available: "yes",
models: "-",
});
});
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
const order: string[] = [];
const stored: unknown[] = [];
const openedUrls: string[] = [];
const result = await runAddCommand(
"beta-oauth",
{ host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" },
{} as never,
{
write: (message) => order.push(`write:${message}`),
openBrowser: (url) => {
openedUrls.push(url);
return true;
},
loginBrowserCredential: async (options) => {
expect(options.flow).toBe("beta-flow");
order.push("browser-login");
options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser.");
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
},
promptText: async () => {
throw new Error("manual prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
setProvider: async (input) => {
order.push("set-provider");
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "oauth", configured: false },
available: false,
error: null,
},
error: null,
};
},
storeCredential: async (input) => {
stored.push(input);
return {
requestId: "store-1",
success: true,
name: input.name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
},
);
expect(order).toEqual([
"set-provider",
"browser-login",
"write:Authorize in the browser.",
"write: https://auth.example.test/browser",
"write:Waiting for you to approve in the browser...",
"write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).",
]);
expect(stored).toEqual([
{
name: "beta-main",
credential: {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
expect(openedUrls).toEqual(["https://auth.example.test/browser"]);
expect(result.data.auth).toBe("Connected");
expect(order.join("\n")).not.toContain("secret");
});
it("uses daemon-run OAuth when --device-code is passed", async () => {
const output: string[] = [];
const oauthCalls: string[] = [];
const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, {
write: (message) => output.push(message),
openBrowser: () => {
throw new Error("browser should not be opened for --device-code");
},
loginBrowserCredential: async () => {
throw new Error("browser login should not run for --device-code");
},
promptText: async () => {
throw new Error("prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
expiresInSeconds: 900,
},
error: null,
};
},
completeOAuth: async (name) => {
oauthCalls.push(`complete:${name}`);
return {
requestId: "complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("ABCD-EFGH");
expect(output.join("\n")).toContain("https://auth.example.test/device");
expect(result.data.auth).toBe("Connected");
});
it("falls back to daemon-run OAuth when the browser cannot open", async () => {
const output: string[] = [];
const oauthCalls: string[] = [];
await runAddCommand("beta-oauth", {}, {} as never, {
write: (message) => output.push(message),
openBrowser: () => false,
loginBrowserCredential: async (options) => {
options.onAuthUrl("https://auth.example.test/browser");
throw new Error("onAuthUrl should have switched to device-code");
},
promptText: async () => {
throw new Error("prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "WXYZ-1234",
verificationUri: "https://auth.example.test/device",
},
error: null,
};
},
completeOAuth: async (name) => {
oauthCalls.push(`complete:${name}`);
return {
requestId: "complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("Browser could not be opened");
expect(output.join("\n")).toContain("WXYZ-1234");
});
it("updates the same instance on repeated add calls", async () => {
const setCalls: unknown[] = [];
const client = createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
});
const dependencies = {
promptSecret: async () => "secret",
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
write: () => {},
connectDaemon: async () => client,
};
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
expect(setCalls).toHaveLength(2);
expect(setCalls).toEqual([
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
]);
});
it("mentions known provider ids for an unknown catalog id", async () => {
await expect(
runAddCommand("missing-key", {}, {} as never, {
promptSecret: async () => {
throw new Error("prompt should not run");
},
promptText: async () => {
throw new Error("text prompt should not run");
},
readStdin: async () => {
throw new Error("stdin should not run");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
setProvider: async () => {
throw new Error("set should not run");
},
}),
}),
).rejects.toMatchObject({
code: "UNKNOWN_PROVIDER",
message:
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
});
});
it("requires the catalog feature flag before reading the catalog", async () => {
const calls: string[] = [];
await expect(
runAddCommand("alpha-key", {}, {} as never, {
promptSecret: async () => {
throw new Error("prompt should not run");
},
promptText: async () => {
throw new Error("text prompt should not run");
},
readStdin: async () => {
throw new Error("stdin should not run");
},
write: () => {},
connectDaemon: async () => ({
waitForServerInfo: async () => ({
status: "server_info",
serverId: "test-daemon",
features: {},
}),
getPaseoAgentCatalog: async () => {
calls.push("catalog");
throw new Error("catalog should not be read");
},
setPaseoAgentProvider: async () => {
calls.push("set");
throw new Error("set should not run");
},
startPaseoAgentOAuth: async () => {
throw new Error("oauth should not run");
},
completePaseoAgentOAuth: async () => {
throw new Error("oauth should not run");
},
storePaseoAgentOAuthCredential: async () => {
throw new Error("oauth should not run");
},
close: async () => {},
}),
}),
).rejects.toMatchObject({
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
});
expect(calls).toEqual([]);
});
it("lets the user choose a provider when no id is passed", async () => {
const setCalls: unknown[] = [];
const output: string[] = [];
await runAddCommand(undefined, {}, {} as never, {
write: (message) => output.push(message),
promptText: async (message) => {
expect(message).toBe("Select provider:");
return "2";
},
promptSecret: async () => "chosen-secret",
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [
apiKeyEntry({ id: "first-key", label: "First Key" }),
apiKeyEntry({ id: "second-key", label: "Second Key" }),
],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(output.join("\n")).toContain("First Key (first-key)");
expect(output.join("\n")).toContain("Second Key (second-key)");
expect(setCalls).toEqual([
expect.objectContaining({ name: "second-key", providerType: "second-key" }),
]);
});
});
+510
View File
@@ -0,0 +1,510 @@
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PaseoAgentCatalogEntry,
PaseoAgentOAuthCredential,
PaseoAgentProviderAuthState,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { loginOAuthBrowser } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import { openBrowserUrl } from "../../utils/open-browser.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
interface ProviderAddOptions extends CommandOptions {
name?: string;
apiKeyStdin?: boolean;
deviceCode?: boolean;
model?: string[];
}
interface ProviderModelInput {
id: string;
label?: string;
api?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
}
interface ProviderConfiguredItem {
name: string;
providerType: string;
label: string;
auth: string;
available: string;
models: string;
}
interface ProviderAddClient extends Pick<
DaemonClient,
| "waitForServerInfo"
| "getPaseoAgentCatalog"
| "setPaseoAgentProvider"
| "startPaseoAgentOAuth"
| "completePaseoAgentOAuth"
| "storePaseoAgentOAuthCredential"
| "close"
> {}
export interface ProviderAddDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderAddClient>;
readStdin: () => Promise<string>;
promptText: (message: string) => Promise<string>;
promptSecret: (message: string) => Promise<string>;
loginBrowserCredential: typeof loginOAuthBrowser;
openBrowser: (url: string) => boolean;
write: (message: string) => void;
}
const defaultDependencies: ProviderAddDependencies = {
connectDaemon: connectToDaemon,
readStdin,
promptText,
promptSecret,
loginBrowserCredential: loginOAuthBrowser,
openBrowser: openBrowserUrl,
write: (message) => console.error(message),
};
export const providerConfiguredSchema: OutputSchema<ProviderConfiguredItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "TYPE", field: "providerType", width: 16 },
{ header: "LABEL", field: "label", width: 22 },
{ header: "AUTH", field: "auth", width: 16 },
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 50 },
],
};
async function readStdin(): Promise<string> {
process.stdin.setEncoding("utf8");
let value = "";
for await (const chunk of process.stdin) {
value += chunk;
}
return value;
}
async function promptText(message: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return (await rl.question(`${message} `)).trim();
} finally {
rl.close();
}
}
async function promptSecret(message: string): Promise<string> {
const output = new Writable({
write(chunk, _encoding, callback) {
const text = String(chunk);
if (text.includes(message)) {
process.stdout.write(text);
}
callback();
},
}) as Writable & { isTTY?: boolean };
output.isTTY = true;
const rl = createInterface({ input: process.stdin, output, terminal: true });
try {
return (await rl.question(`${message} `)).trim();
} finally {
process.stdout.write("\n");
rl.close();
}
}
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
const auth = entry.auth;
const value = auth[field];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function requireAuthField(
entry: PaseoAgentCatalogEntry,
field: string,
description: string,
): string {
const value = authField(entry, field);
if (value) return value;
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
return requireAuthField(entry, "envVar", "an API key environment variable");
}
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
return requireAuthField(entry, "flow", "an OAuth flow");
}
function normalizeModels(rawModels: string[] | undefined): string[] {
return (rawModels ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
}
function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
return entry.models.map((model) => ({
id: model.id,
...(model.label ? { label: model.label } : {}),
...(model.api ? { api: model.api } : {}),
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}));
}
function resolveModels(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
): ProviderModelInput[] | undefined {
const modelIds = normalizeModels(options.model);
if (modelIds.length > 0) {
return modelIds.map((id) => ({ id }));
}
const models = catalogModels(entry);
if (models.length > 0) {
return models;
}
return undefined;
}
async function selectCatalogEntry(
catalog: PaseoAgentCatalogEntry[],
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
if (catalog.length === 0) {
throw {
code: "EMPTY_PROVIDER_CATALOG",
message: "The Paseo daemon returned an empty provider catalog.",
} satisfies CommandError;
}
dependencies.write("Available model providers:");
catalog.forEach((entry, index) => {
dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`);
});
const answer = await dependencies.promptText("Select provider:");
const selectedIndex = Number(answer);
const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined;
const byId = catalog.find((entry) => entry.id === answer);
const selected = byIndex ?? byId;
if (selected) {
return selected;
}
throw {
code: "INVALID_PROVIDER_SELECTION",
message: `Invalid provider selection: ${answer}`,
} satisfies CommandError;
}
async function resolveEntry(
id: string | undefined,
catalog: PaseoAgentCatalogEntry[],
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
if (!id) {
return selectCatalogEntry(catalog, dependencies);
}
const entry = catalog.find((candidate) => candidate.id === id);
if (entry) {
return entry;
}
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
throw {
code: "UNKNOWN_PROVIDER",
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
} satisfies CommandError;
}
function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string {
if (!provider.auth) {
return "not configured";
}
return provider.auth.configured ? "Connected" : "Needs attention";
}
function toConfiguredItem(
provider: RedactedPaseoAgentProviderConfig,
entry: PaseoAgentCatalogEntry,
): ProviderConfiguredItem {
return {
name: provider.name,
providerType: provider.providerType,
label: entry.label,
auth: formatAuthState(provider),
available: provider.available ? "yes" : "no",
models: provider.models.map((model) => model.id).join(", ") || "-",
};
}
function formatDaemonTarget(host: string | undefined): string {
if (!host) {
return "local daemon";
}
try {
if (host.startsWith("tcp://")) {
const url = new URL(host);
url.searchParams.delete("password");
return `selected daemon (${url.toString()})`;
}
} catch {
return `selected daemon (${host})`;
}
return `selected daemon (${host})`;
}
async function resolveApiKey(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<string> {
const envVar = apiKeyEnvVar(entry);
if (options.apiKeyStdin) {
const value = (await dependencies.readStdin()).trim();
if (value) {
return value;
}
throw {
code: "MISSING_API_KEY",
message: "No API key was read from stdin.",
} satisfies CommandError;
}
const hint = authField(entry, "hint");
const keyUrl = authField(entry, "keyUrl");
if (hint) {
dependencies.write(hint);
}
if (keyUrl) {
dependencies.write(`API key URL: ${keyUrl}`);
}
const placeholder = authField(entry, "placeholder") ?? "API key";
const value = await dependencies.promptSecret(
`Enter ${placeholder} (leave empty to use $${envVar}):`,
);
return value || `$${envVar}`;
}
function isBrowserOpenError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as CommandError).code === "BROWSER_OPEN_FAILED"
);
}
function printOAuthAuthorization(
authorization: Awaited<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["authorization"],
dependencies: ProviderAddDependencies,
): void {
if (!authorization) {
dependencies.write("Authorization completed; waiting for the daemon to store credentials...");
return;
}
if (authorization.instructions) {
dependencies.write(authorization.instructions);
}
if (authorization.verificationUri) {
dependencies.write(`Open: ${authorization.verificationUri}`);
}
if (authorization.userCode) {
dependencies.write(`Code: ${authorization.userCode}`);
}
if (authorization.url) {
dependencies.write(`Open: ${authorization.url}`);
}
if (authorization.expiresInSeconds) {
dependencies.write(
`Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`,
);
}
dependencies.write("Waiting for authorization...");
}
async function runDaemonOAuth(
client: ProviderAddClient,
name: string,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
if (!started.success) {
throw {
code: "OAUTH_START_FAILED",
message: started.error ?? "Daemon rejected the OAuth start request.",
} satisfies CommandError;
}
printOAuthAuthorization(started.authorization, dependencies);
const completed = await client.completePaseoAgentOAuth(name);
if (!completed.success) {
throw {
code: "OAUTH_COMPLETE_FAILED",
message: completed.error ?? "Daemon did not complete OAuth.",
} satisfies CommandError;
}
return completed.auth;
}
async function runBrowserOAuth(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
const target = formatDaemonTarget(options.host);
const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({
flow: oauthFlow(entry),
onAuthUrl: (url, instructions) => {
const opened = dependencies.openBrowser(url);
if (!opened) {
throw {
code: "BROWSER_OPEN_FAILED",
message: "Browser could not be opened.",
} satisfies CommandError;
}
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
dependencies.write(` ${url}`);
dependencies.write("Waiting for you to approve in the browser...");
},
onProgress: (message) => dependencies.write(message),
promptForCode: dependencies.promptText,
});
const result = await client.storePaseoAgentOAuthCredential({ name, credential });
if (!result.success) {
throw {
code: "OAUTH_STORE_FAILED",
message: result.error ?? "Daemon rejected the OAuth credential.",
} satisfies CommandError;
}
dependencies.write(`Credential accepted by ${target}.`);
return result.auth;
}
async function configureProvider(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<RedactedPaseoAgentProviderConfig> {
const models = resolveModels(entry, options);
const apiKey =
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
const result = await client.setPaseoAgentProvider({
name,
providerType: entry.id,
options: {
...(apiKey ? { apiKey } : {}),
...(models ? { models } : {}),
},
});
if (!result.success || !result.provider) {
throw {
code: "PROVIDER_CONFIG_FAILED",
message: result.error ?? "Daemon rejected the provider config.",
} satisfies CommandError;
}
return result.provider;
}
async function authenticateOAuthProvider(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
if (options.deviceCode) {
return runDaemonOAuth(client, name, dependencies);
}
try {
return await runBrowserOAuth(client, entry, name, options, dependencies);
} catch (error) {
if (!isBrowserOpenError(error)) {
throw error;
}
dependencies.write("Browser could not be opened; using device-code authorization.");
return runDaemonOAuth(client, name, dependencies);
}
}
export async function runAddCommand(
id: string | undefined,
options: ProviderAddOptions,
_command: Command,
dependencies: Partial<ProviderAddDependencies> = {},
): Promise<SingleResult<ProviderConfiguredItem>> {
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
await requirePaseoAgentCatalogFeature(client);
const catalogResult = await client.getPaseoAgentCatalog();
if (catalogResult.error) {
throw {
code: "PROVIDER_CATALOG_FAILED",
message: catalogResult.error,
} satisfies CommandError;
}
const entry = await resolveEntry(id, catalogResult.catalog, deps);
const name = options.name?.trim() || entry.id;
const provider = await configureProvider(client, entry, name, options, deps);
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
const auth =
entry.auth.kind === "oauth"
? await authenticateOAuthProvider(client, entry, name, options, deps)
: provider.auth;
return {
type: "single",
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
schema: providerConfiguredSchema,
};
} finally {
await client.close().catch(() => {});
}
}
export function addProviderAddOptions(command: Command): Command {
return command
.description("Configure a Paseo Agent model provider")
.argument("[id]", "Catalog provider id; omit to choose interactively")
.option("--name <instanceName>", "Provider instance name (default: provider id)")
.option(
"--model <id>",
"Model ID to expose (repeatable, comma-separated; defaults to catalog models)",
collectMultiple,
[],
)
.option("--api-key-stdin", "Read API key from stdin")
.option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth");
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
function createServerInfo(features: ServerInfoStatusPayload["features"]): ServerInfoStatusPayload {
return {
status: "server_info",
serverId: "test-daemon",
features,
};
}
describe("provider feature gate", () => {
it("waits for delayed server_info before allowing catalog commands", async () => {
let resolveServerInfo: ((serverInfo: ServerInfoStatusPayload) => void) | null = null;
const allowed = requirePaseoAgentCatalogFeature({
waitForServerInfo: async () =>
new Promise<ServerInfoStatusPayload>((resolve) => {
resolveServerInfo = resolve;
}),
});
expect(resolveServerInfo).not.toBeNull();
resolveServerInfo?.(createServerInfo({ paseoAgentCatalog: true }));
await expect(allowed).resolves.toBeUndefined();
});
it("surfaces the server_info wait timeout", async () => {
const timeoutError = new Error("Timed out waiting for server_info status message (5ms)");
await expect(
requirePaseoAgentCatalogFeature({
waitForServerInfo: async () => {
throw timeoutError;
},
}),
).rejects.toThrow("Timed out waiting for server_info status message (5ms)");
});
it("requires the catalog feature when server_info arrives without it", async () => {
await expect(
requirePaseoAgentCatalogFeature({
waitForServerInfo: async () => createServerInfo({ paseoAgentCatalog: false }),
}),
).rejects.toMatchObject({
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
});
});
});
@@ -0,0 +1,17 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
export interface PaseoAgentCatalogFeatureClient extends Pick<DaemonClient, "waitForServerInfo"> {}
export async function requirePaseoAgentCatalogFeature(
client: PaseoAgentCatalogFeatureClient,
): Promise<void> {
const serverInfo = await client.waitForServerInfo();
if (serverInfo.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
} satisfies CommandError;
}
+35 -5
View File
@@ -1,23 +1,53 @@
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js";
import { runRmCommand, type ProviderRmDependencies } from "./rm.js";
import type { ProviderListItem, ProviderLsDependencies } from "./ls.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(): Command {
const provider = new Command("provider").description("Manage agent providers");
export function createProviderCommand(
dependencies: Partial<
ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies
> = {},
): Command {
const provider = new Command("provider").description(
"Manage Paseo Agent model providers and agent provider models",
);
addJsonAndDaemonHostOptions(
provider.command("ls").description("List available providers and status"),
).action(withOutput(runLsCommand));
provider.command("ls").description("List configured Paseo Agent model providers"),
).action(
withOutput<ProviderListItem, []>((options, command) =>
runLsCommand(options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("models")
.description("List models for a provider")
.argument("<provider>", "Provider name (claude, codex, opencode)")
.argument("<provider>", "Provider name")
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
(id, options, command) => runAddCommand(id, options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("rm")
.description("Remove a Paseo Agent model provider")
.argument("<name>", "Provider instance name"),
).action(
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
(name, options, command) => runRmCommand(name, options, command, dependencies),
),
);
return provider;
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { runLsCommand } from "./ls.js";
import { runRmCommand } from "./rm.js";
function createServerInfo() {
return {
status: "server_info",
serverId: "test-daemon",
features: { paseoAgentCatalog: true },
};
}
describe("provider ls", () => {
it("renders an empty configured-provider table with headers", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async () => ({
waitForServerInfo: async () => createServerInfo(),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: [],
error: null,
}),
getPaseoAgentProviders: async () => ({
requestId: "providers-1",
defaultModel: null,
providers: [],
error: null,
}),
close: async () => {},
}),
});
expect(result.data).toEqual([]);
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
expect(render(result, { format: "json" })).toBe("[]");
});
it("lists configured model providers with catalog labels and auth states", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
waitForServerInfo: async () => createServerInfo(),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: [
{
id: "alpha-key",
label: "Alpha Key",
api: "test-api",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
models: [{ id: "alpha-model" }],
},
{
id: "beta-oauth",
label: "Beta OAuth",
api: "test-oauth-api",
baseUrl: "https://beta.example.test",
auth: { kind: "oauth", flow: "beta-flow" },
models: [{ id: "beta-model" }],
},
],
error: null,
}),
getPaseoAgentProviders: async () => ({
requestId: "providers-1",
defaultModel: null,
providers: [
{
name: "alpha-main",
providerType: "alpha-key",
models: [{ id: "alpha-model" }],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
{
name: "beta-main",
providerType: "beta-oauth",
models: [{ id: "beta-model" }],
auth: { kind: "oauth", configured: false, hint: "sign in again" },
available: false,
error: "auth missing",
},
{
name: "manual-main",
providerType: "manual-type",
models: [{ id: "manual-model" }],
available: true,
error: null,
},
],
error: null,
}),
close: async () => {},
};
},
});
expect(result.data).toEqual([
{
name: "alpha-main",
providerType: "alpha-key",
label: "Alpha Key",
auth: "Connected",
available: "yes",
models: "alpha-model",
},
{
name: "beta-main",
providerType: "beta-oauth",
label: "Beta OAuth",
auth: "Needs attention",
available: "no",
models: "beta-model",
},
{
name: "manual-main",
providerType: "manual-type",
label: "manual-type",
auth: "not configured",
available: "yes",
models: "manual-model",
},
]);
const table = render(result, { format: "table", noColor: true });
expect(table).toContain("Connected");
expect(table).toContain("Needs attention");
expect(table).toContain("not configured");
});
});
describe("provider rm", () => {
it("removes a configured model provider", async () => {
const removedNames: string[] = [];
const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, {
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
waitForServerInfo: async () => createServerInfo(),
removePaseoAgentProvider: async (name: string) => {
removedNames.push(name);
return {
requestId: "remove-1",
success: true,
removed: true,
error: null,
};
},
close: async () => {},
};
},
});
expect(removedNames).toEqual(["alpha-main"]);
expect(result.data).toEqual({ name: "alpha-main", removed: "yes" });
});
});
+79 -60
View File
@@ -1,52 +1,57 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { tryConnectToDaemon } from "../../utils/client.js";
import {
renderTable,
renderTableHeader,
type CommandOptions,
type ListResult,
type OutputSchema,
} from "../../output/index.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
export interface ProviderListItem {
provider: ProviderSnapshotEntry["provider"];
name: string;
providerType: string;
label: string;
status: string;
enabled: "Enabled" | "Disabled";
defaultMode: string;
modes: string;
auth: string;
available: string;
models: string;
}
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
interface ProviderLsClient extends Pick<
DaemonClient,
"waitForServerInfo" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close"
> {}
function getStaticProviders(): ProviderListItem[] {
return PROVIDERS;
export interface ProviderLsDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderLsClient>;
}
/** Schema for provider ls output */
const defaultDependencies: ProviderLsDependencies = {
connectDaemon: connectToDaemon,
};
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: "provider",
idField: "name",
columns: [
{ header: "PROVIDER", field: "provider", width: 12 },
{ header: "LABEL", field: "label", width: 16 },
{
header: "STATUS",
field: "status",
width: 12,
color: (value) => {
if (value === "available") return "green";
if (value === "unavailable") return "red";
return undefined;
},
},
{ header: "ENABLED", field: "enabled", width: 10 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
{ header: "NAME", field: "name", width: 20 },
{ header: "TYPE", field: "providerType", width: 16 },
{ header: "LABEL", field: "label", width: 22 },
{ header: "AUTH", field: "auth", width: 16 },
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 30 },
],
renderHuman: (result, options) => {
if (result.type === "list" && result.data.length === 0) {
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
}
return renderTable(result, options);
},
};
export type ProviderLsResult = ListResult<ProviderListItem>;
@@ -55,40 +60,54 @@ export interface ProviderLsOptions extends CommandOptions {
host?: string;
}
function authState(provider: RedactedPaseoAgentProviderConfig): string {
if (!provider.auth) {
return "not configured";
}
return provider.auth.configured ? "Connected" : "Needs attention";
}
function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string {
return catalog.find((entry) => entry.id === providerType)?.label ?? providerType;
}
export async function runLsCommand(
options: ProviderLsOptions,
_command: Command,
dependencies: Partial<ProviderLsDependencies> = {},
): Promise<ProviderLsResult> {
const client = await tryConnectToDaemon({ host: options.host });
if (!client) {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
}
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
const snapshot = await client.getProvidersSnapshot();
await requirePaseoAgentCatalogFeature(client);
const catalogResult = await client.getPaseoAgentCatalog();
if (catalogResult.error) {
throw {
code: "PROVIDER_CATALOG_FAILED",
message: catalogResult.error,
};
}
const providersResult = await client.getPaseoAgentProviders();
if (providersResult.error) {
throw {
code: "PROVIDER_LIST_FAILED",
message: providersResult.error,
};
}
return {
type: "list",
data: snapshot.entries.map((entry) => ({
provider: entry.provider,
label: entry.label ?? entry.provider,
status: entry.status === "ready" ? "available" : entry.status,
enabled: !entry.enabled ? "Disabled" : "Enabled",
defaultMode: entry.defaultModeId ?? "default",
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
data: providersResult.providers.map((provider) => ({
name: provider.name,
providerType: provider.providerType,
label: catalogLabel(catalogResult.catalog, provider.providerType),
auth: authState(provider),
available: provider.available ? "yes" : "no",
models: provider.models.map((model) => model.id).join(", ") || "-",
})),
schema: providerLsSchema,
};
} catch {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
} finally {
await client.close().catch(() => {});
}
+71
View File
@@ -0,0 +1,71 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
interface ProviderRmOptions extends CommandOptions {
host?: string;
}
interface ProviderRemoveItem {
name: string;
removed: string;
}
interface ProviderRmClient extends Pick<
DaemonClient,
"waitForServerInfo" | "removePaseoAgentProvider" | "close"
> {}
export interface ProviderRmDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderRmClient>;
}
const defaultDependencies: ProviderRmDependencies = {
connectDaemon: connectToDaemon,
};
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "REMOVED", field: "removed", width: 10 },
],
};
export async function runRmCommand(
name: string,
options: ProviderRmOptions,
_command: Command,
dependencies: Partial<ProviderRmDependencies> = {},
): Promise<SingleResult<ProviderRemoveItem>> {
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
await requirePaseoAgentCatalogFeature(client);
const result = await client.removePaseoAgentProvider(name);
if (!result.success) {
throw {
code: "PROVIDER_REMOVE_FAILED",
message: result.error ?? "Daemon rejected the provider removal request.",
} satisfies CommandError;
}
return {
type: "single",
data: {
name,
removed: result.removed ? "yes" : "no",
},
schema: providerRemoveSchema,
};
} finally {
await client.close().catch(() => {});
}
}
+1 -4
View File
@@ -28,10 +28,7 @@ export function createScheduleCommand(): Command {
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode")
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { browserOpenCommand, openBrowserUrl } from "./open-browser.js";
describe("browserOpenCommand", () => {
it("opens Windows URLs without cmd.exe shell parsing", () => {
const url =
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback";
expect(browserOpenCommand(url, "win32")).toEqual({
command: "rundll32.exe",
args: ["url.dll,FileProtocolHandler", url],
});
});
it("keeps macOS opener behavior unchanged", () => {
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
expect(browserOpenCommand(url, "darwin")).toEqual({
command: "open",
args: [url],
});
});
it("keeps Linux opener behavior unchanged", () => {
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
expect(browserOpenCommand(url, "linux")).toEqual({
command: "xdg-open",
args: [url],
});
});
});
describe("openBrowserUrl", () => {
it("spawns the resolved opener without launching a real browser", () => {
const spawned: Array<{
command: string;
args: string[];
options: { stdio: "ignore"; detached: true };
}> = [];
const child = {
on: () => child,
unref: () => {},
};
const opened = openBrowserUrl(
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
{
platform: "win32",
spawn: (command, args, options) => {
spawned.push({ command, args, options });
return child;
},
},
);
expect(opened).toBe(true);
expect(spawned).toEqual([
{
command: "rundll32.exe",
args: [
"url.dll,FileProtocolHandler",
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
],
options: { stdio: "ignore", detached: true },
},
]);
});
it("returns false when spawning the opener throws", () => {
const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", {
platform: "linux",
spawn: () => {
throw new Error("spawn failed");
},
});
expect(opened).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
interface BrowserOpenCommand {
command: string;
args: string[];
}
type BrowserOpenSpawn = (
command: string,
args: string[],
options: { stdio: "ignore"; detached: true },
) => Pick<ChildProcessWithoutNullStreams, "on" | "unref">;
interface BrowserOpenDependencies {
platform?: NodeJS.Platform;
spawn?: BrowserOpenSpawn;
}
/**
* Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the
* opener process was spawned, false otherwise. Callers must always print the URL too,
* so a failed/headless open still lets the user copy it.
*/
export function browserOpenCommand(
url: string,
platform: NodeJS.Platform = process.platform,
): BrowserOpenCommand {
switch (platform) {
case "darwin":
return { command: "open", args: [url] };
case "win32":
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
default:
return { command: "xdg-open", args: [url] };
}
}
export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean {
const spawnBrowser = dependencies.spawn ?? spawn;
const { command, args } = browserOpenCommand(url, dependencies.platform);
try {
const child = spawnBrowser(command, args, { stdio: "ignore", detached: true });
child.on("error", () => {});
child.unref();
return true;
} catch {
return false;
}
}
+164 -141
View File
@@ -3,15 +3,18 @@
/**
* Phase 15: Provider Command Tests
*
* Tests provider commands for listing providers and models.
* Provider ls data is static, while provider models are fetched via daemon integration.
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
* Tests provider commands for configured Paseo Agent model providers and agent
* provider model listing. This test uses an isolated daemon to avoid coupling to
* a user's long-running daemon.
*
* Tests:
* - provider --help shows subcommands
* - provider ls lists all providers
* - provider ls --json outputs valid JSON
* - provider ls --quiet outputs provider names only
* - provider ls on a fresh daemon prints an empty configured-provider table
* - provider add stores an API-key model provider without network validation
* - repeated provider add updates the same provider instance
* - provider add rejects unknown catalog ids with known ids
* - provider rm removes a configured model provider
* - provider add uses catalog default models when present
* - provider models claude lists claude models
* - provider models codex lists codex models
* - provider models opencode lists opencode models
@@ -20,14 +23,7 @@
*/
import assert from "node:assert";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
createE2ETestContext,
createTempDirs,
runPaseoCli,
startTestDaemon,
} from "./helpers/test-daemon.ts";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Provider Commands ===\n");
@@ -38,10 +34,26 @@ interface ProviderModel {
}
interface ProviderListRow {
provider: string;
name: string;
providerType: string;
label: string;
status: string;
enabled: string;
auth: string;
available: string;
models: string;
}
interface CliResult {
exitCode: number;
stdout: string;
stderr: string;
}
function assertExitCode(result: CliResult, expected: number, message: string): void {
assert.strictEqual(result.exitCode, expected, `${message}\nstderr:\n${result.stderr}`);
}
function assertNonZeroExitCode(result: CliResult, message: string): void {
assert.notStrictEqual(result.exitCode, 0, `${message}\nstderr:\n${result.stderr}`);
}
const EXPECTED_CLAUDE_MODELS = [
@@ -131,6 +143,24 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
return attemptRun(1);
}
function parseProviderListJson(stdout: string): ProviderListRow[] {
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
assert(Array.isArray(data), "provider ls --json output should be an array");
return data;
}
async function getProviderRows(): Promise<ProviderListRow[]> {
const result = await ctx.paseo(["provider", "ls", "--json"]);
assertExitCode(result, 0, "provider ls --json should exit 0");
return parseProviderListJson(result.stdout);
}
function assertProviderTableHeader(stdout: string): void {
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
assert(stdout.includes(header), `provider ls table should include ${header}`);
}
}
function assertClaudeModels(data: ProviderModel[]): void {
assert.strictEqual(
data.length,
@@ -165,144 +195,137 @@ try {
{
console.log("Test 1: provider --help shows subcommands");
const result = await ctx.paseo(["provider", "--help"]);
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
assertExitCode(result, 0, "provider --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls");
assert(result.stdout.includes("add"), "help should mention add");
assert(result.stdout.includes("rm"), "help should mention rm");
assert(result.stdout.includes("models"), "help should mention models");
console.log("✓ provider --help shows subcommands\n");
}
// Test 2: provider ls lists all providers
// Test 2: provider ls on a fresh daemon shows no configured providers
{
console.log("Test 2: provider ls lists all providers");
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
const result = await ctx.paseo(["provider", "ls"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
assert(result.stdout.includes("claude"), "output should include claude");
assert(result.stdout.includes("codex"), "output should include codex");
assert(result.stdout.includes("opencode"), "output should include opencode");
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
assert(
result.stdout.includes("available") ||
result.stdout.includes("loading") ||
result.stdout.includes("unavailable"),
"output should show a provider status",
);
console.log("✓ provider ls lists all providers\n");
assertExitCode(result, 0, "provider ls should exit 0");
assertProviderTableHeader(result.stdout);
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
assertExitCode(jsonResult, 0, "provider ls --json should exit 0");
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
}
// Test 3: provider ls --json outputs valid JSON
// Test 3: provider add openrouter stores a dummy key without network validation
{
console.log("Test 3: provider ls --json outputs valid JSON");
const result = await ctx.paseo(["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const data = JSON.parse(result.stdout.trim());
assert(Array.isArray(data), "output should be an array");
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
assert(
data.some((p: { provider: string }) => p.provider === "claude"),
"should include claude",
);
assert(
data.some((p: { provider: string }) => p.provider === "codex"),
"should include codex",
);
assert(
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
const rows = data as ProviderListRow[];
for (const provider of ["claude", "codex", "opencode"] as const) {
const row = rows.find((p) => p.provider === provider);
assert(row, `should include ${provider}`);
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
}
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
stdin: "dummy-openrouter-key\n",
});
assertExitCode(result, 0, "provider add should exit 0");
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
assert(result.stdout.includes("yes"), "add output should show the provider as available");
const omp = rows.find((p) => p.provider === "omp");
assert(omp, "should include omp");
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
console.log("✓ provider ls --json outputs valid JSON\n");
const rows = await getProviderRows();
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
assert.deepStrictEqual(rows[0], {
name: "openrouter",
providerType: "openrouter",
label: "OpenRouter",
auth: "Connected",
available: "yes",
models: "-",
});
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
}
// Test 4: provider ls includes disabled providers
// Test 4: provider add is idempotent for the same instance name
{
console.log("Test 4: provider ls includes disabled providers");
const { paseoHome, workDir } = await createTempDirs();
await writeFile(
join(paseoHome, "config.json"),
JSON.stringify(
{
version: 1,
agents: {
providers: {
claude: {
enabled: false,
},
},
},
},
null,
2,
) + "\n",
console.log("Test 4: provider add is idempotent for the same instance name");
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
stdin: "dummy-openrouter-key-2\n",
});
assertExitCode(result, 0, "provider add should exit 0");
const rows = await getProviderRows();
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
assert.strictEqual(rows[0]?.name, "openrouter");
assert.strictEqual(rows[0]?.label, "OpenRouter");
console.log("✓ provider add is idempotent for the same instance name\n");
}
// Test 5: provider add rejects unknown catalog ids with known ids
{
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
stdin: "dummy-key\n",
});
assertNonZeroExitCode(result, "provider add should fail for unknown ids");
const output = result.stdout + result.stderr;
assert(output.includes("nonsense-id"), "error should mention the requested id");
assert(output.includes("Known provider ids"), "error should mention known provider ids");
assert(output.includes("openrouter"), "known ids should include openrouter");
assert(output.includes("kimi"), "known ids should include kimi");
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
}
// Test 6: provider rm removes a configured provider
{
console.log("Test 6: provider rm removes a configured provider");
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
assertExitCode(result, 0, "provider rm should exit 0");
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
assert(result.stdout.includes("yes"), "rm output should report removal");
const rows = await getProviderRows();
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
const table = await ctx.paseo(["provider", "ls"]);
assertExitCode(table, 0, "provider ls should stay successful after removal");
assertProviderTableHeader(table.stdout);
console.log("✓ provider rm removes a configured provider\n");
}
// Test 7: provider add uses catalog default models when present
{
console.log("Test 7: provider add uses catalog default models when present");
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
stdin: "dummy-kimi-key\n",
});
assertExitCode(result, 0, "provider add kimi should exit 0");
assert(result.stdout.includes("kimi"), "add output should include the instance name");
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
const rows = await getProviderRows();
const kimi = rows.find((row) => row.name === "kimi");
assert(kimi, "provider ls should include the kimi instance");
assert.strictEqual(kimi.label, "Kimi Coding Plan");
assert.strictEqual(kimi.auth, "Connected");
assert.strictEqual(kimi.available, "yes");
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
assert(
kimi.models
.split(",")
.map((model) => model.trim())
.filter(Boolean).length > 0,
"kimi should list at least one catalog-derived model id",
);
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
try {
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
const claude = data.find((p) => p.provider === "claude");
assert(claude, "disabled claude provider should stay in provider ls");
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
const opencode = data.find((p) => p.provider === "opencode");
assert(opencode, "enabled opencode provider should stay in provider ls");
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
assert.notStrictEqual(
modelsResult.exitCode,
0,
"provider models should fail for disabled providers",
);
const output = modelsResult.stdout + modelsResult.stderr;
assert(
output.includes("Provider claude is disabled"),
"provider models should surface the daemon disabled error",
);
assert(
!output.includes("claude-sonnet"),
"provider models should not print fallback models for disabled providers",
);
} finally {
await disabledCtx.stop();
}
console.log("✓ provider ls includes disabled providers\n");
console.log("✓ provider add uses catalog default models when present\n");
}
// Test 5: provider ls --quiet outputs provider names only
// Test 8: provider models claude lists canonical model aliases
{
console.log("Test 5: provider ls --quiet outputs provider names only");
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
assert(lines.includes("claude"), "should include claude");
assert(lines.includes("codex"), "should include codex");
assert(lines.includes("opencode"), "should include opencode");
console.log("✓ provider ls --quiet outputs provider names only\n");
}
// Test 6: provider models claude lists canonical model aliases
{
console.log("Test 6: provider models claude lists canonical model aliases");
console.log("Test 8: provider models claude lists canonical model aliases");
const data = await runProviderModelsJson("claude");
assertClaudeModels(data);
console.log("✓ provider models claude lists canonical model aliases\n");
}
// Test 7: provider models codex includes concrete codex model IDs
// Test 9: provider models codex includes concrete codex model IDs
{
console.log("Test 7: provider models codex includes concrete codex model IDs");
console.log("Test 9: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
assert(data.length >= 1, "codex model list should not be empty");
const ids = data.map((m) => m.id);
@@ -322,9 +345,9 @@ try {
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
// Test 8: provider models opencode returns namespaced model IDs
// Test 10: provider models opencode returns namespaced model IDs
{
console.log("Test 8: provider models opencode returns namespaced model IDs");
console.log("Test 10: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
assert(data.length >= 1, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
@@ -343,11 +366,11 @@ try {
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
// Test 9: provider models unknown fails with error
// Test 11: provider models unknown fails with error
{
console.log("Test 9: provider models unknown fails with error");
console.log("Test 11: provider models unknown fails with error");
const result = await ctx.paseo(["provider", "models", "unknown"]);
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
assertNonZeroExitCode(result, "should fail for unknown provider");
const output = result.stdout + result.stderr;
assert(
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
@@ -356,9 +379,9 @@ try {
console.log("✓ provider models unknown fails with error\n");
}
// Test 10: provider models --json outputs valid JSON
// Test 12: provider models --json outputs valid JSON
{
console.log("Test 10: provider models --json outputs valid JSON");
console.log("Test 12: provider models --json outputs valid JSON");
const data = await runProviderModelsJson("claude");
assert(Array.isArray(data), "output should be an array");
assert(
@@ -371,15 +394,15 @@ try {
console.log("✓ provider models --json outputs valid JSON\n");
}
// Test 11: provider models --quiet outputs model IDs only
// Test 13: provider models --quiet outputs model IDs only
{
console.log("Test 11: provider models --quiet outputs model IDs only");
console.log("Test 13: provider models --quiet outputs model IDs only");
assert(
claudeModelIdsFromJson.length > 0,
"claude model IDs should be captured from --json output",
);
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
assertExitCode(result, 0, "provider models claude --quiet should exit 0");
const lines = result.stdout.trim().split("\n").filter(Boolean);
assert.strictEqual(
lines.length,
+27 -7
View File
@@ -43,6 +43,13 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
const TEST_DAEMON_HOST = "127.0.0.1";
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
// local developer credentials cannot hide CI-missing-auth failures.
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
] as const;
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
@@ -59,6 +66,14 @@ function createOutputCapture(): OutputCapture {
return { value: "", truncated: false };
}
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const scrubbed = { ...env };
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
delete scrubbed[key];
}
return scrubbed;
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString();
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
@@ -234,7 +249,7 @@ export async function startTestDaemon(options?: {
// Start daemon process using tsx to run TypeScript directly
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
@@ -242,7 +257,7 @@ export async function startTestDaemon(options?: {
// Force no TTY to prevent QR code output
CI: "true",
...options?.env,
},
}),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
@@ -336,6 +351,7 @@ export async function runPaseoCli(
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
stdin?: string;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000;
@@ -346,15 +362,15 @@ export async function runPaseoCli(
return new Promise((resolve, reject) => {
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
},
}),
cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
});
@@ -369,6 +385,10 @@ export async function runPaseoCli(
appendOutputCapture(stderr, data);
});
if (options?.stdin !== undefined) {
proc.stdin?.end(options.stdin);
}
const timeoutId = setTimeout(() => {
if (proc.pid) {
signalProcessTree(proc.pid, "SIGKILL");
@@ -406,7 +426,7 @@ export async function createE2ETestContext(options?: {
/** Run a paseo CLI command against this daemon */
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
) => Promise<{
exitCode: number;
stdout: string;
@@ -418,7 +438,7 @@ export async function createE2ETestContext(options?: {
const paseo = (
args: string[],
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
) => runPaseoCli(ctx, args, opts);
return {
+43 -4
View File
@@ -19,6 +19,44 @@ const TEST_ENV_DEFAULTS = {
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
// local developer credentials cannot hide CI-missing-auth failures.
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
] as const;
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const scrubbed = { ...env };
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
delete scrubbed[key];
}
return scrubbed;
}
function testCliEnv(port: number): NodeJS.ProcessEnv {
return scrubPaseoAgentProviderAuthEnv({
...process.env,
PASEO_HOST: `localhost:${port}`,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
});
}
function testDaemonEnv(port: number, paseoHome: string): NodeJS.ProcessEnv {
return scrubPaseoAgentProviderAuthEnv({
...process.env,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
CI: "true",
});
}
function killPidTree(pid: number, signal: NodeJS.Signals): void {
if (!Number.isInteger(pid) || pid <= 0) {
@@ -85,7 +123,7 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
*/
async function probeDaemon(port: number): Promise<boolean> {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
const result = await $({ env: testCliEnv(port) })`paseo agent ls`.nothrow();
return result.exitCode === 0;
} catch {
return false;
@@ -110,8 +148,9 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
*/
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
$.verbose = false;
const daemon =
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
const daemon = $({
env: testDaemonEnv(port, paseoHome),
})`paseo daemon start --foreground`.nothrow();
return daemon;
}
@@ -125,7 +164,7 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false;
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
return $({ env: testCliEnv(port) })`paseo ${args}`.nothrow();
};
// Cleanup function
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.104-beta.2",
"version": "0.1.103",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/relay": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"zod": "^4.4.3"
},
"devDependencies": {
+75
View File
@@ -75,6 +75,30 @@ function createMockTransport() {
return {
transport,
sent,
sendServerInfo: (input: { serverId?: string; features?: Record<string, boolean> } = {}) => {
onMessage(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: input.serverId ?? `srv_test_${serverInfoOrdinal++}`,
hostname: null,
version: null,
...(input.features ? { features: input.features } : {}),
},
},
}),
);
},
triggerOpenWithoutServerInfo: (options?: { preserveSent?: boolean }) => {
onOpen();
if (!options?.preserveSent) {
// Ignore HELLO handshake payloads in assertions.
sent.length = 0;
}
},
triggerOpen: (options?: { preserveSent?: boolean }) => {
onOpen();
if (!options?.preserveSent) {
@@ -191,6 +215,57 @@ test("advertises consumer-provided browser automation capabilities", async () =>
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
});
test("waitForServerInfo resolves when server_info arrives after the socket opens", async () => {
vi.useFakeTimers();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "server_info_wait_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const serverInfoPromise = client.waitForServerInfo(100);
const connectPromise = client.connect();
mock.triggerOpenWithoutServerInfo();
setTimeout(() => {
mock.sendServerInfo({
serverId: "srv_delayed_server_info",
features: { paseoAgentCatalog: true },
});
}, 25);
await vi.advanceTimersByTimeAsync(25);
await expect(serverInfoPromise).resolves.toMatchObject({
status: "server_info",
serverId: "srv_delayed_server_info",
features: { paseoAgentCatalog: true },
});
await connectPromise;
});
test("waitForServerInfo rejects clearly when server_info never arrives", async () => {
vi.useFakeTimers();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "server_info_timeout_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const serverInfoPromise = client.waitForServerInfo(50);
const rejection = expect(serverInfoPromise).rejects.toThrow(
"Timed out waiting for server_info status message (50ms)",
);
await vi.advanceTimersByTimeAsync(50);
await rejection;
});
const noopLogger: Logger = {
debug: () => {},
info: () => {},
+156 -3
View File
@@ -94,7 +94,21 @@ import type {
AgentProvider,
AgentSessionConfig,
} from "@getpaseo/protocol/agent-types";
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
import type {
MutableDaemonConfig,
MutableDaemonConfigPatch,
PaseoAgentGetCatalogResponse,
PaseoAgentGetProvidersResponse,
PaseoAgentOAuthCompleteResponse,
PaseoAgentOAuthCredential,
PaseoAgentOAuthStartResponse,
PaseoAgentOAuthStoreCredentialResponse,
PaseoAgentRenameProviderRequest,
PaseoAgentRenameProviderResponse,
PaseoAgentRemoveProviderResponse,
PaseoAgentSetProviderRequest,
PaseoAgentSetProviderResponse,
} from "@getpaseo/protocol/messages";
import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
import {
@@ -828,6 +842,7 @@ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_SERVER_INFO_TIMEOUT_MS = 10_000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
@@ -3861,6 +3876,119 @@ export class DaemonClient {
this.sendSessionMessageStrict(response);
}
async getPaseoAgentProviders(
requestId?: string,
): Promise<PaseoAgentGetProvidersResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.get_providers.request",
},
timeout: 10000,
});
}
async getPaseoAgentCatalog(requestId?: string): Promise<PaseoAgentGetCatalogResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.get_catalog.request",
},
timeout: 10000,
});
}
async setPaseoAgentProvider(
input: Omit<PaseoAgentSetProviderRequest, "type" | "requestId"> & { requestId?: string },
): Promise<PaseoAgentSetProviderResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "config.paseo_agent.set_provider.request",
name: input.name,
providerType: input.providerType,
...(input.displayName ? { displayName: input.displayName } : {}),
options: input.options,
},
timeout: 30000,
});
}
async removePaseoAgentProvider(
name: string,
requestId?: string,
): Promise<PaseoAgentRemoveProviderResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.remove_provider.request",
name,
},
timeout: 30000,
});
}
async renamePaseoAgentProvider(
input: Omit<PaseoAgentRenameProviderRequest, "type" | "requestId"> & { requestId?: string },
): Promise<PaseoAgentRenameProviderResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "config.paseo_agent.rename_provider.request",
name: input.name,
displayName: input.displayName,
},
timeout: 30000,
});
}
async startPaseoAgentOAuth(
name: string,
options?: string | { mode?: string; requestId?: string },
): Promise<PaseoAgentOAuthStartResponse["payload"]> {
const requestId = typeof options === "string" ? options : options?.requestId;
const mode = typeof options === "string" ? undefined : options?.mode;
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.oauth.start.request",
name,
...(mode ? { mode } : {}),
},
timeout: 30000,
});
}
async completePaseoAgentOAuth(
name: string,
requestId?: string,
): Promise<PaseoAgentOAuthCompleteResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.oauth.complete.request",
name,
},
timeout: 30000,
});
}
async storePaseoAgentOAuthCredential(input: {
name: string;
credential: PaseoAgentOAuthCredential;
requestId?: string;
}): Promise<PaseoAgentOAuthStoreCredentialResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "config.paseo_agent.oauth.store_credential.request",
name: input.name,
credential: input.credential,
},
timeout: 30000,
});
}
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4634,6 +4762,29 @@ export class DaemonClient {
return this.lastServerInfoMessage;
}
async waitForServerInfo(
timeoutMs = DEFAULT_SERVER_INFO_TIMEOUT_MS,
): Promise<ServerInfoStatusPayload> {
if (this.lastServerInfoMessage) {
return this.lastServerInfoMessage;
}
const { promise } = this.waitForWithCancel<ServerInfoStatusPayload>(
(msg) => {
if (msg.type !== "status") {
return null;
}
return parseServerInfoStatusPayload(msg.payload);
},
timeoutMs,
{
timeoutMessage: `Timed out waiting for server_info status message (${timeoutMs}ms)`,
},
);
return promise;
}
private resolveTransportUrlForAttempt(): string {
return this.config.url;
}
@@ -5151,10 +5302,12 @@ export class DaemonClient {
private waitForWithCancel<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
_options?: { skipQueue?: boolean },
options?: { skipQueue?: boolean; timeoutMessage?: string },
): WaitHandle<T> {
// Capture stack trace at call site, not inside setTimeout
const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
const timeoutError = new Error(
options?.timeoutMessage ?? `Timeout waiting for message (${timeout}ms)`,
);
let waiter: Waiter<T> | null = null;
let settled = false;
@@ -1,2 +0,0 @@
out/
fatal-error.txt
@@ -1,89 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Capture Harness Target</title>
<style>
html,
body {
width: 1280px;
min-height: 1600px;
margin: 0;
background: #ff00ff;
overflow: auto;
font-family: Arial, Helvetica, sans-serif;
}
.label {
position: absolute;
left: 48px;
top: 48px;
color: #ffffff;
font-size: 72px;
font-weight: 800;
line-height: 1;
text-shadow:
5px 5px 0 #000000,
-2px -2px 0 #000000;
}
.sub {
position: absolute;
left: 54px;
top: 150px;
color: #000000;
background: #ffffff;
font-size: 34px;
font-weight: 700;
padding: 10px 18px;
}
.block {
position: absolute;
left: 56px;
top: 220px;
width: 360px;
height: 92px;
background: linear-gradient(
90deg,
#000000 0 25%,
#ffffff 25% 50%,
#000000 50% 75%,
#ffffff 75% 100%
);
border: 8px solid #000000;
}
.bottom {
position: absolute;
left: 48px;
top: 1370px;
color: #ffffff;
font-size: 60px;
font-weight: 800;
text-shadow: 5px 5px 0 #000000;
}
</style>
</head>
<body>
<div class="label">PASEO CAPTURE HARNESS</div>
<div class="sub">LOCAL MAGENTA TARGET 1280x800 VIEWPORT</div>
<div class="block"></div>
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
<script>
const params = new URLSearchParams(window.location.search);
const label = params.get("label");
const sub = params.get("sub");
const bottom = params.get("bottom");
if (label) {
document.querySelector(".label").textContent = label;
}
if (sub) {
document.querySelector(".sub").textContent = sub;
}
if (bottom) {
document.querySelector(".bottom").textContent = bottom;
}
</script>
</body>
</html>
@@ -1,93 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Delayed Capture Harness Target</title>
<style>
html,
body {
width: 1280px;
min-height: 1600px;
margin: 0;
background: #202020;
overflow: auto;
font-family: Arial, Helvetica, sans-serif;
}
.content {
opacity: 0;
}
body.ready {
background: #ff00ff;
}
body.ready .content {
opacity: 1;
}
.label {
position: absolute;
left: 48px;
top: 48px;
color: #ffffff;
font-size: 72px;
font-weight: 800;
line-height: 1;
text-shadow:
5px 5px 0 #000000,
-2px -2px 0 #000000;
}
.sub {
position: absolute;
left: 54px;
top: 150px;
color: #000000;
background: #ffffff;
font-size: 34px;
font-weight: 700;
padding: 10px 18px;
}
.block {
position: absolute;
left: 56px;
top: 220px;
width: 360px;
height: 92px;
background: linear-gradient(
90deg,
#000000 0 25%,
#ffffff 25% 50%,
#000000 50% 75%,
#ffffff 75% 100%
);
border: 8px solid #000000;
}
.bottom {
position: absolute;
left: 48px;
top: 1370px;
color: #ffffff;
font-size: 60px;
font-weight: 800;
text-shadow: 5px 5px 0 #000000;
}
</style>
<script>
setTimeout(() => {
document.body.classList.add("ready");
}, 1500);
</script>
</head>
<body>
<div class="content">
<div class="label">PASEO CAPTURE HARNESS</div>
<div class="sub">DELAYED FIRST PAINT TARGET</div>
<div class="block"></div>
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
</div>
</body>
</html>
-376
View File
@@ -1,376 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Browser Capture Harness</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
background: #202020;
overflow: hidden;
font-family: Arial, sans-serif;
}
#paseo-browser-resident-webviews {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 1;
pointer-events: none;
display: block;
visibility: visible;
}
.capture-harness-webview {
display: inline-flex;
flex: 0 0 auto;
width: 1280px;
height: 800px;
border: 0;
background: transparent;
}
</style>
</head>
<body>
<div id="paseo-browser-resident-webviews" aria-hidden="true"></div>
<script>
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const host = document.getElementById("paseo-browser-resident-webviews");
const params = new URLSearchParams(window.location.search);
const requestedWebviewCount = params.has("webviewCount")
? Number(params.get("webviewCount"))
: 2;
const webviewCount = Number.isFinite(requestedWebviewCount)
? Math.max(0, requestedWebviewCount)
: 2;
const webviews = [];
const activeTokens = new Set();
let nextTokenId = 0;
let permanentParkingState = params.get("permanentParkingState") || "";
function appendHarnessWebview(sourceUrl) {
const index = webviews.length;
const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview";
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = sourceUrl;
applyStackedWebviewStyle(webview);
host.appendChild(webview);
webviews.push(webview);
return index;
}
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
}
function applyHostParking() {
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyWebviewBaseStyle(webview) {
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.border = "0";
webview.style.background = "transparent";
}
function applyLegacyVerticalWebviewStyle(webview, index) {
applyWebviewBaseStyle(webview);
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = index === 0 ? "0" : "4px";
webview.style.zIndex = "";
}
function applyStackedWebviewStyle(webview, zIndex = "0") {
applyWebviewBaseStyle(webview);
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = zIndex;
}
function applyParking() {
permanentParkingState = "";
applyHostParking();
webviews.forEach((webview) => {
applyStackedWebviewStyle(webview);
});
}
function applyLegacyVerticalParking() {
applyHostParking();
webviews.forEach((webview, index) => {
applyLegacyVerticalWebviewStyle(webview, index);
});
}
function applyCapturePrep() {
permanentParkingState = "";
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.zIndex = "1";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyHostPermanentBase() {
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "visible";
host.style.transform = "";
host.style.transformOrigin = "";
}
function applyPermanentWebviewBase(webview, index) {
applyStackedWebviewStyle(webview);
webview.style.opacity = "";
webview.style.transform = "";
webview.style.pointerEvents = "";
webview.style.zIndex = "0";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.left = "0";
webview.style.top = "0";
}
function applyPermanentParkingState(stateName) {
permanentParkingState = stateName;
activeTokens.clear();
applyHostPermanentBase();
webviews.forEach(applyPermanentWebviewBase);
if (stateName === "p1-overflow-1x1") {
return state();
}
if (stateName === "p2-clip-path-1x1") {
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "visible";
host.style.clipPath = "inset(1px)";
return state();
}
if (stateName === "p3-opacity-0") {
host.style.opacity = "0";
return state();
}
if (stateName === "p4-transform-scale-0") {
host.style.transform = "scale(0)";
return state();
}
if (stateName === "p4-transform-scale-0001") {
host.style.transform = "scale(0.001)";
return state();
}
if (stateName === "p5-webview-0x0") {
webviews.forEach((webview) => {
webview.style.width = "0";
webview.style.height = "0";
});
return state();
}
if (stateName === "p5-webview-1x1") {
webviews.forEach((webview) => {
webview.style.width = "1px";
webview.style.height = "1px";
});
return state();
}
if (stateName === "p6-z-index-negative") {
host.style.zIndex = "-1";
return state();
}
if (stateName === "p7-opacity-001") {
host.style.opacity = "0.01";
return state();
}
throw new Error(`Unknown permanent parking state: ${stateName}`);
}
function applyTargetStacking(targetIndex) {
webviews.forEach((webview, index) => {
applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0");
});
}
function waitFrames(count) {
return new Promise((resolve) => {
function step(remaining) {
if (remaining <= 0) {
resolve();
return;
}
requestAnimationFrame(() => step(remaining - 1));
}
step(count);
});
}
function state(targetIndex = 0) {
const hostRect = host.getBoundingClientRect();
const targetWebview = webviews[targetIndex] || webviews[0];
const webviewRect = targetWebview?.getBoundingClientRect();
return {
hostStyle: {
left: host.style.left,
top: host.style.top,
width: host.style.width,
height: host.style.height,
overflow: host.style.overflow,
opacity: host.style.opacity,
pointerEvents: host.style.pointerEvents,
zIndex: host.style.zIndex,
clipPath: host.style.clipPath,
transform: host.style.transform,
},
hostRect: {
x: hostRect.x,
y: hostRect.y,
width: hostRect.width,
height: hostRect.height,
},
webviewRect: {
x: webviewRect?.x ?? 0,
y: webviewRect?.y ?? 0,
width: webviewRect?.width ?? 0,
height: webviewRect?.height ?? 0,
},
webviews: webviews.map((webview) => {
const rect = webview.getBoundingClientRect();
return {
id: webview.id,
zIndex: webview.style.zIndex,
position: webview.style.position,
opacity: webview.style.opacity,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
}),
};
}
window.captureHarness = {
async waitForFrames(count = 2) {
await waitFrames(count);
return state();
},
webContentsIds() {
return webviews.map((webview) =>
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
);
},
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},
addPermanentWebview(sourceUrl, stateName) {
const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
applyPermanentParkingState(stateName || permanentParkingState);
return index;
},
async applyPermanentParkingState(stateName) {
const nextState = applyPermanentParkingState(stateName);
await waitFrames(2);
return nextState;
},
async prepareForPixelCapture(targetIndex = 0) {
const token = `capture-${++nextTokenId}`;
activeTokens.add(token);
applyCapturePrep();
applyTargetStacking(targetIndex);
await waitFrames(2);
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async prepareLegacyVerticalPixelCapture(targetIndex = 0) {
const token = `legacy-capture-${++nextTokenId}`;
activeTokens.add(token);
applyLegacyVerticalParking();
applyCapturePrep();
await waitFrames(2);
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async restorePixelCapture(token) {
activeTokens.delete(token);
if (activeTokens.size === 0) {
applyParking();
}
await waitFrames(2);
return state();
},
async restoreLegacyVerticalParking(token) {
activeTokens.delete(token);
applyLegacyVerticalParking();
await waitFrames(2);
return state();
},
async restoreParking() {
activeTokens.clear();
applyParking();
await waitFrames(2);
return state();
},
};
if (permanentParkingState) {
applyPermanentParkingState(permanentParkingState);
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.2",
"version": "0.1.103",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -17,7 +17,6 @@
"scripts": {
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"capture-harness": "./capture-harness/run.sh",
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"test": "vitest run",
@@ -1,184 +1,18 @@
import type { Rectangle } from "electron";
import { describe, expect, test } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
import { describe, expect, it } from "vitest";
class FakeImage implements TabImage {
public toPNG(): Uint8Array {
return new Uint8Array([137, 80, 78, 71]);
}
import { sanitizeDownloadFileName } from "./ipc.js";
public getSize(): { width: number; height: number } {
return { width: 640, height: 480 };
}
}
class FakeDebugger {
public attachedProtocolVersions: string[] = [];
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
public isAttached(): boolean {
return this.attachedProtocolVersions.length > 0;
}
public attach(protocolVersion?: string): void {
this.attachedProtocolVersions.push(protocolVersion ?? "");
}
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ command, params: params ?? {} });
return { ok: true };
}
}
type ConsoleMessageListener = (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void;
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
}> = [];
public readonly invalidations: string[] = [];
private consoleMessageListener: ConsoleMessageListener | null = null;
private destroyedListener: (() => void) | null = null;
public destroyed = false;
public constructor(public readonly id: number) {}
public getURL(): string {
return "https://example.com";
}
public getTitle(): string {
return "Example";
}
public canGoBack(): boolean {
return false;
}
public canGoForward(): boolean {
return false;
}
public isLoading(): boolean {
return false;
}
public isDestroyed(): boolean {
return this.destroyed;
}
public async executeJavaScript(): Promise<unknown> {
return null;
}
public async loadURL(): Promise<void> {}
public goBack(): void {}
public goForward(): void {}
public reload(): void {}
public async capturePage(
rect?: Rectangle,
options?: { stayHidden?: boolean },
): Promise<TabImage> {
this.captures.push({ rect, options });
return new FakeImage();
}
public invalidate(): void {
this.invalidations.push("invalidate");
}
public on(event: "console-message", listener: ConsoleMessageListener): void {
expect(event).toBe("console-message");
this.consoleMessageListener = listener;
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public emitConsoleMessage(input: {
level: unknown;
message: unknown;
line: unknown;
sourceId: unknown;
}): void {
if (!this.consoleMessageListener) {
throw new Error("Console listener was not registered");
}
this.consoleMessageListener({}, input.level, input.message, input.line, input.sourceId);
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("browser automation IPC adapter", () => {
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
const contents = new FakeWebContents(20);
const tab = adaptWebContents(contents);
const image = await tab.capturePage({ stayHidden: false });
tab.invalidate();
expect(image.getSize()).toEqual({ width: 640, height: 480 });
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
expect(contents.invalidations).toEqual(["invalidate"]);
describe("browser automation IPC", () => {
it("strips directories from agent-supplied download filenames", () => {
expect(
sanitizeDownloadFileName({
url: "https://example.com/fallback.txt",
fileName: "../../.ssh/authorized_keys",
}),
).toBe("authorized_keys");
});
test("collects console messages until the guest is destroyed", () => {
const contents = new FakeWebContents(21);
const tab = adaptWebContents(contents);
contents.emitConsoleMessage({
level: "warning",
message: "hello",
line: 12,
sourceId: "https://example.com/app.js",
});
expect(tab.getConsoleMessages?.()).toEqual([
{
level: "warning",
message: "hello",
line: 12,
source: "https://example.com/app.js",
timestamp: expect.any(Number),
},
]);
contents.destroy();
expect(tab.getConsoleMessages?.()).toEqual([]);
});
test("attaches the debugger before sending a CDP command", async () => {
const contents = new FakeWebContents(22);
const tab = adaptWebContents(contents);
const result = await tab.sendDebugCommand?.("Page.captureScreenshot", {
format: "png",
});
expect(result).toEqual({ ok: true });
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
expect(contents.debugger.commands).toEqual([
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
it("falls back to a safe filename when the URL has no basename", () => {
expect(sanitizeDownloadFileName({ url: "https://example.com/" })).toBe("download");
});
});
@@ -1,14 +1,22 @@
import type { Rectangle } from "electron";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import type { WebContents } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { BrowserAutomationConsoleLogEntry } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
import type {
BrowserAutomationConsoleLogEntry,
BrowserAutomationCookieEntry,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry } from "./service.js";
import { executeAutomationCommand } from "./service.js";
import {
listRegisteredPaseoBrowserIds,
listRegisteredPaseoBrowserIdsForWorkspace,
getPaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserId,
getAgentActivePaseoBrowserId,
getPaseoBrowserWorkspaceId,
} from "../browser-webviews/index.js";
@@ -20,45 +28,7 @@ interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
interface WebContentsDebugger {
isAttached(): boolean;
attach(protocolVersion?: string): void;
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
}
interface ConsoleMessageEmitter {
on(
event: "console-message",
listener: (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void,
): void;
once(event: "destroyed", listener: () => void): void;
}
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
readonly id: number;
readonly debugger: WebContentsDebugger;
getURL(): string;
getTitle(): string;
canGoBack(): boolean;
canGoForward(): boolean;
isLoading(): boolean;
isDestroyed(): boolean;
executeJavaScript(code: string): Promise<unknown>;
loadURL(url: string): Promise<void>;
goBack(): void;
goForward(): void;
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void;
}
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
function adaptWebContents(contents: WebContents): TabContents {
observeConsoleMessages(contents);
return {
id: contents.id,
@@ -73,19 +43,67 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
goBack: () => contents.goBack(),
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
invalidate: () => contents.invalidate(),
capturePage: () => contents.capturePage(),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
getCookies: async (url: string) =>
(await contents.session.cookies.get({ url })).map(normalizeCookie),
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
},
printToPDF: async (options?: Record<string, unknown>) => contents.printToPDF(options ?? {}),
downloadURL: (input) => downloadWithContents(contents, input),
};
}
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
function downloadWithContents(
contents: WebContents,
input: { url: string; fileName?: string },
): Promise<{ filePath: string; totalBytes?: number; state: string }> {
const downloadDir = join(tmpdir(), "paseo-browser-downloads");
mkdirSync(downloadDir, { recursive: true });
const filePath = join(downloadDir, sanitizeDownloadFileName(input));
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
contents.session.off("will-download", onDownload);
reject(new Error(`Timed out waiting for browser download: ${input.url}`));
}, 30_000);
function onDownload(_event: Electron.Event, item: Electron.DownloadItem): void {
if (item.getURL() !== input.url) {
return;
}
clearTimeout(timeout);
contents.session.off("will-download", onDownload);
item.setSavePath(filePath);
item.once("done", (_doneEvent, state) => {
resolve({ filePath, totalBytes: item.getTotalBytes(), state });
});
}
contents.session.on("will-download", onDownload);
contents.downloadURL(input.url);
});
}
export function sanitizeDownloadFileName(input: { url: string; fileName?: string }): string {
const requestedName = input.fileName ?? basename(new URL(input.url).pathname);
return basename(requestedName) || "download";
}
function normalizeCookie(cookie: Electron.Cookie): BrowserAutomationCookieEntry {
return {
name: cookie.name,
value: cookie.value,
...(cookie.domain ? { domain: cookie.domain } : {}),
...(cookie.path ? { path: cookie.path } : {}),
secure: cookie.secure,
httpOnly: cookie.httpOnly,
...(typeof cookie.expirationDate === "number" ? { expirationDate: cookie.expirationDate } : {}),
};
}
function observeConsoleMessages(contents: WebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
}
@@ -128,7 +146,12 @@ function createRegistry(): BrowserRegistry {
return contents ? adaptWebContents(contents) : null;
},
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveTabContents(workspaceId: string): TabContents | null {
const contents = getWorkspaceActivePaseoBrowserWebContents(workspaceId);
return contents ? adaptWebContents(contents) : null;
},
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
getAgentActiveBrowserId: getAgentActivePaseoBrowserId,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -44,9 +44,7 @@ describe("BrowserSnapshotEngine", () => {
ok: false,
reason: "stale_ref",
});
await expect(
engine.select({ browserId: "browser-1", page, ref: "@e1", value: "us" }),
).resolves.toEqual({
await expect(engine.focus({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
});
@@ -108,6 +108,31 @@ export class BrowserSnapshotEngine {
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async focus(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFocusScript(selector));
}
async clear(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClearScript(selector));
}
async check(input: {
browserId: string;
page: SnapshotPage;
ref: string;
checked: boolean;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildCheckScript(selector, input.checked));
}
async select(input: {
browserId: string;
page: SnapshotPage;
@@ -279,6 +304,57 @@ function buildKeypressScript(selector: string | undefined, key: string): string
})()`;
}
function buildFocusScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
return document.activeElement === element;
})()`;
}
function buildClearScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
if ('value' in element) {
element.value = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
return true;
})()`;
}
function buildCheckScript(selector: string, checked: boolean): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextChecked = ${JSON.stringify(checked)};
if ('checked' in element) {
element.checked = nextChecked;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'radio') {
element.setAttribute('aria-checked', String(nextChecked));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
})()`;
}
function buildSelectScript(selector: string, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
@@ -1,44 +0,0 @@
import { describe, expect, test } from "vitest";
import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js";
class FakeRegisteredWebContents {
public readonly backgroundThrottlingCalls: boolean[] = [];
private destroyedListener: (() => void) | null = null;
private destroyed = false;
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public setBackgroundThrottling(allowed: boolean): void {
this.backgroundThrottlingCalls.push(allowed);
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("registerPaseoBrowserWebContents", () => {
test("disables guest background throttling once when the webview is registered", () => {
const contents = new FakeRegisteredWebContents(9001);
registerPaseoBrowserWebContents(contents, "browser-throttle");
expect(contents.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle");
contents.destroy();
expect(getPaseoBrowserIdForWebContents(contents)).toBeNull();
expect(contents.backgroundThrottlingCalls).toEqual([false]);
});
});

Some files were not shown because too many files have changed in this diff Show More