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
Mohamed Boudra 2263469342 fix(server): show Claude usage when a quota window has no scheduled reset (#1855)
The Anthropic usage API returns resets_at: null when a window has no
active session. The schema only allowed string/undefined, so the parse
threw and the whole Claude provider surfaced as an error in the quota
panel. The consuming code already handled null; only the schema was
stricter than the API.
2026-07-02 08:30:36 +02:00
Xisheng Parker ZhaoandMohamed Boudra 5a0ea3385e feat(app): add ByteDance TRAE CLI to the ACP provider catalog (#1831)
* feat(app): add ByteDance TRAE CLI to the ACP provider catalog

Adds the official ByteDance TRAE CLI (traecli) to the in-app ACP provider
catalog. traecli is ACP-native, so Paseo drives it over the standard ACP
transport via `traecli acp serve`, reusing the generic ACP client exactly
like Kiro, Qoder, Cursor, and Gemini.

- catalog: traecli entry (command ["traecli","acp","serve"], manual install)
- icon: vendored TRAE monogram SVG + registered icon name
- docs: supported-providers list + CHANGELOG
- tests: focused command + icon-name assertions

Verified against the real traecli ACP surface in multica-ai/multica#4724.

* feat(app): use the real TRAE app logo for the traecli provider icon

* docs: drop TRAE CLI changelog entry

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-01 22:50:15 +02:00
paseo-ai[bot] 3708eddbcd fix: update lockfile signatures and Nix hash [skip ci] 2026-07-01 20:42:13 +00:00
Mohamed Boudra f41dde8c72 chore(release): sync Sonnet 5 stable release to main 2026-07-01 22:36:37 +02:00
Mohamed Boudra d3e8f77914 Keep composer autocomplete visible after route hops (#1851)
* fix(app): keep composer autocomplete visible after route hops

Workspace callers now express only the target workspace. The navigation helper chooses whether to pop to a mounted host route or fall back, and active-workspace tracking ignores stale hidden route params while app-wide routes are foregrounded.

* test(app): address workspace navigation review

* fix(app): parse decoded legacy workspace routes

* fix(app): ignore decoded legacy tab routes

* test(app): use canonical offline workspace route
2026-07-01 21:55:17 +02:00
Mohamed Boudra 48d07dedb5 Keep New Workspace drafts when archiving a workspace (#1838)
* fix(app): preserve new workspace drafts across archive

The New Workspace project picker was still tied to the last active workspace. Archiving that workspace could make the picker fall back to another project, changing the draft key and clearing the composer.

* refactor(app): extract new workspace project selection

Keep the archive-draft fix out of the screen effect by moving the project selection transition into a small pure model with focused coverage.

* test(app): seed host in archive draft regression

Make the new Playwright regression self-contained by seeding the local host before opening the app shell.

* refactor(app): home new workspace project picker state

* fix(app): reset project picker on capability hydration

* fix(app): preserve manual project picks during hydration

* fix(app): keep project selection through archive gaps

* fix(app): narrow archive-gap project preservation

* fix(app): refresh new workspace project selection
2026-07-01 21:53:16 +02:00
Mohamed Boudra 31e9a210d0 Open a project with Cmd+O (#1849)
* feat(app): open a project with Cmd+O

Cmd+O now opens the project picker (was Cmd+Shift+O). New worktree,
which previously used Cmd+O, no longer has a keyboard shortcut; its
sidebar button still creates one.

* test(app): assert old Cmd+Shift+O open-project binding is unbound

Locks in the rebind to Cmd+O so re-adding a Cmd+Shift+O binding would fail CI.

* fix(app): keep Open project override id stable and forward Cmd+O in desktop browser

- Binding ids for Open project keep their original names so existing user
  shortcut overrides (keyed by binding id) survive the Cmd+Shift+O -> Cmd+O rebind.
- Forward "o" from focused browser webviews in the desktop app so Cmd/Ctrl+O
  reaches the renderer and opens the project picker there too.
2026-07-02 02:11:11 +08:00
Mohamed Boudra bf5e3b47e7 Add a Schedules screen to manage recurring agents (#1246)
* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
2026-06-30 22:58:49 +02:00
huiliaoningandClaude Opus 4.8 b8b66816ca feat(browser): inspect, annotate, and grab page elements for the agent (#1708)
* feat(browser): inspect, annotate, and grab page elements for the agent

Build a design-review flow on top of the in-app Electron browser so users
can send page elements to their coding agent with context.

- Annotate: pick an element, write a comment, choose an intent
  (fix/change/question/approve); the element context + intent + comment go
  to the agent as text, and a cropped screenshot rides along as an image.
- Grab: pick an element to copy its info + screenshot straight to the
  system clipboard (no comment), with a toast on success/failure.
- Hover inspector: in select mode each element shows a floating label with
  tag, id/class, React component name, and pixel size.
- Page markers: annotated elements on the current page get numbered badges
  that track scroll/resize.
- Device sizes: a viewport-size menu (responsive + 13 common device presets)
  renders the page centered in a fixed-size frame.
- Toolbar buttons now expose hover tooltips and no longer get squeezed out
  on narrow panes; the element selector is available to all desktop users
  (previously dev-only).

Screenshots and clipboard writes go through new Electron main-process IPCs
(capturePage + clipboard) so they work regardless of webview focus. Element
screenshots are referenced by id in the workspace attachment store and are
protected from the draft-store attachment GC. All new strings are
translated across the six supported locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browser): write element clipboard exactly once

Resolve the image before writing so a combined text+image grab no longer
does a redundant text-only writeText() first (which flashed an intermediate
clipboard state). Addresses greptile review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: add browser annotate/grab/devices strings for ja and pt-BR

main added Japanese and Brazilian Portuguese locales after this branch was
created; add the browser annotation, grab, and device-size keys to keep all
locales in sync with en. resources.test.ts parity passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:58:16 +02:00
Christoph Leiter 2b46caa20e feat(app): clearer interactive question card
Fixes #1642
2026-06-30 22:53:12 +02:00
Mohamed Boudra 20385bdb50 Add opt-in browser tools for desktop tabs
* Add opt-in browser tools for desktop tabs

Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs.

* Fix browser tools CI expectations

* Address browser tools review findings

* Restrict browser file automation paths

* Fix browser upload test on Windows

* Harden browser navigation inputs

* Make browser tools create usable tabs

* Update browser MCP empty-state test

* Fail browser tab creation when registration times out

* Fix browser screenshots for agents

* Hide disabled browser tools from agents

* Address browser tools architecture review

* Replace browser tools review tests

* Wrap browser tab registration errors

* Mock Expo Router in app unit tests

* Handle invalid browser automation requests

* Return browser failure on desktop disconnect

* Update browser disconnect websocket test

* Relax browser timeout polling test

* Handle invalid browser responses

* Return browser failure when send fails

* Remove local diagnostics and fixture paths

* Fix dev service home fallback

* Use worktree home for dev services

* Use managed daemon in desktop dev

* fix(browser): keep agent tabs addressable

Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution.

* refactor(browser): register tools through catalog

Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter.

* fix(settings): translate browser tools host error
2026-06-30 22:33:18 +02:00
Mohamed Boudra db03b1f3fd docs: link paseo-vscode extension 2026-06-30 16:18:03 +02:00
Mohamed Boudra cb486c3a5a Speed up app Playwright CI (#1830)
* ci(playwright): shard app e2e in CI

Run the app Playwright suite across isolated CI shards and keep restarted E2E daemons on the same speech-disabled setup path as global setup.

* test(app): share disabled speech e2e env
2026-06-30 12:37:47 +02:00
paseo-ai[bot] 7dad7a377c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-30 10:05:14 +00:00
Mohamed Boudra e32c50f2d7 chore(release): cut 0.1.102 2026-06-30 12:00:31 +02:00
Mohamed Boudra e3eb633582 docs: update changelog for 0.1.102 2026-06-30 11:23:41 +02:00
Mohamed Boudra beac4544fd chore: update ACP provider catalog 2026-06-30 11:22:12 +02:00
Mohamed Boudra c2271ff0ca Place turn footers after trailing tools (#1827)
* fix(app): place turn footers after trailing tools

Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.

* test(app): cover split turn footer placement
2026-06-30 10:51:53 +02:00
Mohamed Boudra f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00
Mohamed Boudra e9431517a0 Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter

Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).

The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.

* test(app): extract host-filter and command-center e2e DSL helpers

Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.

* fix(app): drop the icon from the host filter "All hosts" item

* fix(app): make multi-host e2e seeding order-independent

The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.

Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
2026-06-30 09:40:24 +02:00
Xisheng Parker Zhao 4e28b0941b fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)
* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
2026-06-30 09:36:48 +02:00
Mohamed Boudra 7c6152663e Show found desktop updates after manual checks (#1815)
* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
2026-06-30 08:12:15 +02:00
paseo-ai[bot] 5fc53c576e fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 22:07:09 +00:00
287 changed files with 35538 additions and 1936 deletions
+7 -2
View File
@@ -241,6 +241,11 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: playwright (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -280,7 +285,7 @@ jobs:
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -288,7 +293,7 @@ jobs:
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
name: playwright-results-${{ matrix.shard }}
path: |
packages/app/test-results/
packages/app/playwright-report/
+35 -31
View File
@@ -1,51 +1,55 @@
# Changelog
## 0.1.102-beta.2 - 2026-06-29
## 0.1.103 - 2026-07-01
### Added
- Fork a chat from any assistant answer ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
## 0.1.102 - 2026-06-30
### Added
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
- Multi-host workspace rows show which host they belong to ([#1775](https://github.com/getpaseo/paseo/pull/1775))
- Host switcher rows show the connection currently in use
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
- New Workspace starts from the current project when opened inside a project ([#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show feedback while checking for updates ([#1808](https://github.com/getpaseo/paseo/pull/1808))
- Slow host requests time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
- ACP provider catalog updated to the latest registry versions
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile opens the saved workspace reliably on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename existing workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays at your scroll position when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
- Chat actions wait until running tool calls finish
- Claude subagent narration no longer leaks into chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Agent lists keep working when project records go stale ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews work with drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
- `paseo daemon status` reports daemon health even when agent details are unavailable ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly after checkout ([#1804](https://github.com/getpaseo/paseo/pull/1804))
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
## 0.1.101 - 2026-06-26
+1 -1
View File
@@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [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/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 |
+1
View File
@@ -154,6 +154,7 @@ npm run typecheck
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---
+1
View File
@@ -169,6 +169,7 @@ npm run typecheck
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---
+1
View File
@@ -154,6 +154,7 @@ npm run typecheck
## 社区
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS
+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.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are 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
+13 -4
View File
@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { voice: { apiKey: string, baseUrl: string } },
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
+4
View File
@@ -72,6 +72,10 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
### React render profiling
The app has a gated React render profiler in
+15 -7
View File
@@ -47,18 +47,27 @@ dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops
When app-wide routes such as `/new` navigate back into a host workspace, use
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
leaf workspace URL.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, express only the destination with `navigateToWorkspace()`.
Do not make the caller branch on its current route.
The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
root host route and pass the nested workspace screen, or Expo Router can append
extra hidden workspace deck entries.
root host route and pass the nested workspace screen when a host route is
already mounted, or Expo Router can append extra hidden workspace deck entries.
The workspace navigation helper inspects the mounted navigation state to make
that decision; if no host route is mounted yet, it falls back to ordinary route
navigation.
Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen.
Hidden host routes may keep their local params while an app-wide route is
foregrounded. Active-workspace observers must prefer the current pathname and
only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Params
Required dynamic params belong to the matched route.
@@ -112,8 +121,7 @@ Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did an app-wide route return to a workspace? Use
`navigateToHostWorkspaceRoute()`.
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
- [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership
+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`).
+1 -1
View File
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `which opencode`: `opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
+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
@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
## Agent authentication in tests
+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-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
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.102-beta.2",
"version": "0.1.103",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,49 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
});
+28 -3
View File
@@ -5,7 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
@@ -153,6 +153,13 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
}
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
await page.getByTestId("settings-back-to-workspace").click();
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
}
async function expectSingleCurrentWorkspaceDeckEntry(
page: Page,
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
@@ -351,7 +358,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
const sessions: MockAgentWorkspace[] = [];
@@ -384,10 +391,28 @@ test.describe("Composer autocomplete", () => {
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openAppWideNewWorkspace(page);
await openSettingsThenBackToWorkspace(page);
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSessions(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: third.workspaceId,
});
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
+18
View File
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {
+3 -23
View File
@@ -10,6 +10,7 @@ import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
@@ -405,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
@@ -675,7 +667,7 @@ interface DaemonSpawnArgs {
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const env: NodeJS.ProcessEnv = {
const env = withDisabledE2ESpeechEnv({
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
@@ -684,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
@@ -0,0 +1,9 @@
import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
+13 -11
View File
@@ -5,6 +5,7 @@ import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
import { withDisabledE2ESpeechEnv } from "./speech-env";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const env = withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
});
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
+25 -2
View File
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { escapeRegex } from "./regex";
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
+79
View File
@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
// the view as multi-host without standing up a second daemon.
//
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
// and reseeds on every navigation. We write the full registry here and set the fixture's
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
// relabels the seeded primary host so assertions can target a distinctive name.
export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
},
);
await page.reload();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
}
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
}
export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}
+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);
}
}
+56 -21
View File
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
// --- Navigation ---
export async function openProjects(page: Page): Promise<void> {
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
// --- WebSocket helpers ---
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
// until the test explicitly allows recovery. Closing the transport leaves the
// client-side RPC pending across reconnects, so this injects the same correlated
// rpc_error shape the daemon emits for failed async session requests.
export async function installReadTransportFailure(
page: Page,
): Promise<{ allowRecovery: () => void }> {
let shouldFailReads = true;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (armed && typeof message === "string") {
try {
const envelope = JSON.parse(message) as {
type?: string;
message?: { type?: string };
};
if (
envelope.type === "session" &&
envelope.message?.type === "read_project_config_request"
) {
armed = false;
void ws.close({ code: 1001 });
return;
}
} catch {
// binary or malformed frame — pass through
const sessionMessage = getSessionMessage(message);
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
const requestId = sessionMessage.requestId;
if (typeof requestId === "string") {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "read_project_config_request",
error: "Test read transport failure.",
code: "transport",
},
},
}),
);
}
return;
}
try {
server.send(message);
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
}
});
});
return {
allowRecovery() {
shouldFailReads = false;
},
};
}
// Installs a transparent WS proxy that can later drop all active daemon connections
+22 -21
View File
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
// Nav tabs only render for multi-question cards (hidden for a lone question).
if (input.total > 1) {
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
}
}
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
// Options render as radios (single-select) or checkboxes (multi-select), so match
// either role by accessible name.
function questionOption(page: Page, option: string) {
const card = page.getByTestId("question-form-card").first();
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
}
// The multi-question nav renders as a tablist; each question is a tab.
function questionNavTab(page: Page, input: { index: number; total: number }) {
return page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: option })
.click();
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await questionOption(page, option).click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
await questionNavTab(page, input).click();
}
export async function expectQuestionNavigationEnabled(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await expect(
page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toBeEnabled();
await expect(questionNavTab(page, input)).toBeEnabled();
}
export async function fillQuestionAnswer(
@@ -0,0 +1,249 @@
import { type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
import { type SeededWorkspace } from "./seed-client";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const FAKE_HOST_MODEL_ID = "fake-host-model";
const FAKE_HOST_MODEL_LABEL = "Fake host model";
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
type WebSocketMessage = string | Buffer;
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
return JSON.stringify({
type: "session",
message: {
type,
payload,
},
});
}
function buildFakeProviderEntries(nowIso: string) {
return [
{
provider: "mock",
label: "Mock",
status: "ready",
enabled: true,
fetchedAt: nowIso,
models: [
{
provider: "mock",
id: FAKE_HOST_MODEL_ID,
label: FAKE_HOST_MODEL_LABEL,
isDefault: true,
},
],
modes: [{ id: "load-test", label: "Load test" }],
defaultModeId: "load-test",
},
];
}
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
const parsed = parseJson(message);
if (!parsed || typeof parsed !== "object") {
return null;
}
const envelope = parsed as { type?: string; message?: SessionRequest };
if (envelope.type !== "session" || !envelope.message) {
return null;
}
return envelope.message;
}
function getRequestId(request: SessionRequest): string {
return typeof request.requestId === "string" ? request.requestId : "fake-request";
}
export async function buildFakeScheduleHostWorkspace(
workspace: SeededWorkspace,
): Promise<FakeScheduleHostWorkspace> {
const workspaceList = await workspace.client.fetchWorkspaces({
filter: { projectId: workspace.projectId },
});
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
if (!baseWorkspace) {
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
}
const projectId = `${workspace.projectId}-fake-host`;
const cwd = `${workspace.repoPath}-fake-host`;
return {
serverId: "schedule-fake-host",
projectId,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
id: `${baseWorkspace.id}-fake-host`,
projectId,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
projectRootPath: cwd,
workspaceDirectory: cwd,
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
project: undefined,
},
};
}
export async function installFakeScheduleHost(input: {
page: Page;
port: string;
serverId: string;
workspace: Record<string, unknown>;
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
ws.onMessage((message) => {
const parsed = parseJson(message);
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
ws.send(
buildSessionMessage("status", {
status: "server_info",
serverId: input.serverId,
hostname: "fake-schedule-host",
version: "0.0.0-e2e",
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectAdd: true,
projectRemove: true,
worktreeRestore: true,
},
}),
);
return;
}
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
const request = readSessionRequest(message);
if (!request) {
return;
}
const requestId = getRequestId(request);
const now = Date.now();
const nowIso = new Date(now).toISOString();
switch (request.type) {
case "ping":
ws.send(
buildSessionMessage("pong", {
requestId,
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
serverReceivedAt: now,
serverSentAt: now,
}),
);
return;
case "fetch_workspaces_request":
ws.send(
buildSessionMessage("fetch_workspaces_response", {
requestId,
entries: [input.workspace],
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
requestId,
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "get_providers_snapshot_request":
ws.send(
buildSessionMessage("get_providers_snapshot_response", {
requestId,
entries: buildFakeProviderEntries(nowIso),
generatedAt: nowIso,
}),
);
return;
case "refresh_providers_snapshot_request":
ws.send(
buildSessionMessage("refresh_providers_snapshot_response", {
requestId,
acknowledged: true,
}),
);
return;
case "schedule/list":
ws.send(
buildSessionMessage("schedule/list/response", {
requestId,
schedules: [],
error: null,
}),
);
return;
}
});
});
}
export async function addFakeScheduleHostAndReload(input: {
page: Page;
serverId: string;
label: string;
port: string;
}): Promise<void> {
const host = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
});
await input.page.evaluate(
({ seededHost, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
},
);
await input.page.reload();
}
+32
View File
@@ -0,0 +1,32 @@
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
const DISABLED_E2E_SPEECH_ENV = {
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
} as const;
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
// Default app E2E does not cover speech flows; keep restarts from starting
// background local-model downloads for unrelated tests.
const next: NodeJS.ProcessEnv = {
...env,
...DISABLED_E2E_SPEECH_ENV,
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete next[key];
}
return next;
}
@@ -10,6 +10,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
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 per-project
@@ -105,6 +106,54 @@ test.describe("New workspace entry points", () => {
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
const otherProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "aa-new-workspace-archive-other-",
});
const rememberedProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "zz-new-workspace-archive-remembered-",
});
const serverId = getServerId();
const draftText = "keep this new workspace draft";
try {
await seedSavedSettingsHosts(page, [
{
serverId,
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await composer.fill(draftText);
await expect(composer).toHaveValue(draftText);
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
await expect(composer).toHaveValue(draftText);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
} finally {
await otherProject.cleanup();
await rememberedProject.cleanup();
}
});
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
page,
}) => {
@@ -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);
});
});
+5 -4
View File
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
page,
editableProject,
}) => {
// Drop the WS connection the moment a read_project_config_request is sent.
// Subsequent connections are proxied transparently so Reload can succeed.
await installReadTransportFailure(page);
// Reject read_project_config_request calls until the user clicks Reload.
// This keeps automatic reconnect refetches from racing past the callout.
const transportFailure = await installReadTransportFailure(page);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
@@ -292,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
await expectProjectSettingsError(page, "transport");
await expectProjectSettingsFormHidden(page);
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
// Retry Reload until the refetch wins any in-flight error-state rendering.
transportFailure.allowRecovery();
await expect(async () => {
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "transport", 3_000);
@@ -0,0 +1,92 @@
import { expect, test } from "./fixtures";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleSeedClient {
scheduleCreate(input: {
prompt: string;
name?: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title: string;
};
};
runOnCreate: boolean;
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
name,
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
modeId: "load-test",
title: name,
},
},
runOnCreate: false,
});
if (!result.schedule) {
throw new Error(result.error ?? "Failed to seed schedule");
}
return result.schedule.id;
}
function ignoreScheduleDeleteError(): void {}
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
await (workspace.client as unknown as ScheduleSeedClient)
.scheduleDelete({ id })
.catch(ignoreScheduleDeleteError);
}
test.describe("Schedules", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const cleanup of cleanupTasks.toReversed()) {
await cleanup();
}
cleanupTasks.length = 0;
});
test("edit form hydrates the scheduled model selection", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Hydrate model ${Date.now()}`;
const scheduleId = await seedMockSchedule(workspace, scheduleName);
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText("ten-second-stream");
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
workspace.projectDisplayName,
{ timeout: 30_000 },
);
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
timeout: 30_000,
});
});
});
@@ -0,0 +1,156 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addFakeScheduleHostAndReload,
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";
interface ScheduleListItem {
id: string;
name: string | null;
target: { type: string; config?: { cwd?: string } };
}
interface ScheduleSeedClient {
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function selectModelByLabel(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: /select model/i }).click();
const popup = page.getByTestId("combobox-desktop-container");
await expect(popup).toBeVisible({ timeout: 30_000 });
await popup.getByText(label, { exact: true }).click();
await expect(popup).toHaveCount(0, { timeout: 30_000 });
}
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === name);
if (schedule) {
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
}
}
async function expectScheduleCreatedForProject(input: {
workspace: SeededWorkspace;
name: string;
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
expect(schedule).toEqual(
expect.objectContaining({
name: input.name,
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
}),
}),
}),
);
}
test.describe("Schedules project target", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const cleanup of cleanupTasks.toReversed()) {
await cleanup();
}
cleanupTasks.length = 0;
});
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Project schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
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);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Summarize the project status.");
await page.getByRole("button", { name: "Cron" }).click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
});
test("clears the selected model when the chosen project moves to another host", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
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();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await selectModelByLabel(page, "Ten second stream");
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
fakeHost.projectDisplayName,
);
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
await page.getByLabel("Prompt").fill("Run on the fake host project.");
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
});
});
@@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarDisplayPreferences,
selectAllHostsFilter,
toggleHostFilter,
} from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const SECONDARY_HOST_ID = "host-filter-secondary";
test.describe("Sidebar host filter (multi-select)", () => {
test.describe.configure({ timeout: 120_000 });
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
const serverId = getServerId();
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
);
try {
// A second (offline) host is enough to surface the host filter without a second daemon.
await gotoAppShell(page);
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarDisplayPreferences(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
// Pin the primary host — its workspace stays visible.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toBeVisible();
// Add the secondary host without clearing the primary. Under single-select this would replace
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
await toggleHostFilter(page, SECONDARY_HOST_ID);
await expect(workspaceRow).toBeVisible();
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
// Back to all hosts — the workspace returns.
await selectAllHostsFilter(page);
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
} finally {
await seeded.cleanup();
}
});
});
@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
import { buildSeededHost } from "./helpers/daemon-registry";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
env: withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: input.paseoHome,
PASEO_SERVER_ID: SERVER_ID,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: input.origin,
PASEO_RELAY_ENABLED: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});
@@ -256,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
await page.goto(
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
await expectHostConnectingOrOffline(page);
await expectMenuButtonVisible(page);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"private": true,
"main": "index.ts",
"scripts": {
+85 -6
View File
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
return owners;
}
function footerAssistantIds(layout: StreamLayout): string[] {
return [
...layout.history.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...layout.liveHead.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
}
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
return Object.fromEntries(
[...layout.history, ...layout.liveHead].flatMap((item) =>
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
),
);
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
@@ -291,7 +311,7 @@ describe("layoutStream", () => {
});
it.each(["web", "android"] as const)(
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
@@ -302,13 +322,36 @@ describe("layoutStream", () => {
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([assistant.id]);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([tool.id]);
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
"places split live-head tool footer using the assistant from history on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant],
head: [tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[tool.id]: assistant.id,
});
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for footer content while placing after the visible turn end on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
@@ -329,10 +372,46 @@ describe("layoutStream", () => {
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
expect(footerOwners(layout)).toEqual([latestTool.id]);
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const secondAssistant = assistantMessage("a2", 4);
const secondTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
userMessage("u2", 3),
secondAssistant,
secondTool,
userMessage("u3", 6),
],
timingIds: [firstAssistant.id, secondAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
firstAssistant.id,
);
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
secondAssistant.id,
);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[firstAssistant.id]: firstAssistant.id,
[secondTool.id]: secondAssistant.id,
});
},
);
+72 -101
View File
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
function createTurnFooterHost(input: {
@@ -68,25 +75,45 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantIndexInTurn(input: {
function findLatestAssistantInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
while (true) {
for (
;
index >= 0 && index < items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
}
}
if (
!canCrossBoundary ||
!input.boundaryAboveItems ||
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
items = input.boundaryAboveItems;
index = input.boundaryAboveIndex;
canCrossBoundary = false;
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
@@ -100,104 +127,54 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (assistantIndex === null) {
return null;
}
const item = footerItems[assistantIndex];
if (!item || item.kind !== "assistant_message") {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: assistantIndex,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
function findTurnEndIndexInSegment(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number {
let endIndex = input.startIndex;
for (
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "below")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
break;
}
endIndex = index;
}
return endIndex;
}
function shouldRenderCompletedFooter(input: {
function resolveCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryIndex: number | null;
boundaryBelowItem: StreamItem | null;
}): boolean {
if (
input.item.kind !== "assistant_message" ||
input.auxiliaryTurnFooter?.itemId === input.item.id
) {
return false;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}): TurnFooterHost | null {
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
return null;
}
if (
input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running")
) {
return true;
}
if (!isToolSequenceItem(input.belowItem)) {
return false;
}
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
return false;
}
const turnEndIndex = findTurnEndIndexInSegment({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const belowTurnItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index: turnEndIndex,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
if (input.agentStatus === "running" && belowTurnItem?.kind !== "user_message") {
return false;
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: input.items,
startIndex: turnEndIndex,
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
return assistantIndex === input.index;
}
function isToolSequenceItem(
@@ -270,24 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryIndex: input.boundaryIndex,
boundaryBelowItem: input.boundaryBelowItem,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
return {
item,
@@ -328,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
@@ -346,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -354,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
});
byKey.set(historyCacheKey, history);
}
@@ -363,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -371,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
});
return {
+7 -2
View File
@@ -87,6 +87,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
@@ -133,14 +134,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
function PushNotificationRouter() {
const router = useRouter();
const pathname = usePathname();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -313,6 +313,9 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
if (hasConfiguredLocalDaemonOverride()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
@@ -889,6 +892,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
(pathname === "/open-project" ||
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
@@ -947,6 +951,7 @@ function RootStack() {
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
function HostAgentReadyRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
navigateToAgent({
serverId,
agentId,
currentPathname: pathname,
});
}
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
serverId,
agentId,
workspaceId,
currentPathname: pathname,
});
return;
}
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, pathname, router, serverId]);
}, [agentId, client, isConnected, router, serverId]);
return null;
}
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SchedulesScreen } from "@/screens/schedules-screen";
export default function SchedulesRoute() {
return (
<HostRouteBootstrapBoundary>
<SchedulesScreen />
</HostRouteBootstrapBoundary>
);
}
File diff suppressed because one or more lines are too long
@@ -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)) {
+16
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;
@@ -42,6 +48,16 @@ export interface BrowserElementAttachment {
} | null;
parentChain: string[];
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;
* referenced by id so the draft-store GC keeps it alive.
*/
screenshot?: AttachmentMetadata;
formatted: string;
}
@@ -0,0 +1,375 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
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" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
class FakeDaemonClient {
public sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
type: "browser.automation.execute.request",
handler: (request: BrowserAutomationExecuteRequest) => void,
): () => void {
expect(type).toBe("browser.automation.execute.request");
this.handler = handler;
return () => {
if (this.handler === handler) {
this.handler = null;
}
};
}
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
this.sentResponses.push(response);
}
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
this.handler?.(nextRequest);
}
}
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-1",
command: { command: "list_tabs", args: {} },
};
}
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "/repo",
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true as const,
result: {
command: "list_tabs" as const,
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true as const,
result: {
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: "/repo",
url: browser.url,
title: browser.title,
isActive: true,
isLoading: false,
}));
}
describe("mountBrowserAutomationHandler", () => {
beforeEach(() => {
useBrowserStore.setState({ browsersById: {} });
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
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: "/repo",
});
if (!workspaceKey) throw new Error("expected workspace key");
const focusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
if (!focusedTabId) throw new Error("expected focused tab");
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () =>
({
browser: {
executeAutomationCommand,
registerWorkspaceBrowser,
setWorkspaceActiveBrowser,
setAgentActiveBrowser,
},
}) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
});
client.receive(browserNewTabRequest());
await flushPromises();
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 } }),
);
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("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,
});
client.receive(browserNewTabRequest());
await waitForAsyncWork();
expect(client.sentResponses[0]?.payload).toMatchObject({
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
retryable: true,
},
});
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
expect.objectContaining({ url: "https://example.com" }),
);
expect(client.sentResponses[0]?.payload).not.toMatchObject({
ok: true,
result: { command: "new_tab" },
});
});
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,
});
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: {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
},
},
]);
});
test("missing bridge sends browser_unsupported", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({ client, getHost: () => null });
client.receive(browserAutomationRequest());
await flushPromises();
expect(client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
retryable: false,
},
},
},
]);
});
test("typed bridge errors become failure responses", async () => {
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 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 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 } }),
});
unsubscribe();
client.receive(browserAutomationRequest());
await flushPromises();
expect(executeAutomationCommand).not.toHaveBeenCalled();
expect(client.sentResponses).toEqual([]);
});
});
@@ -0,0 +1,343 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
interface BrowserAutomationClient {
on(
type: "browser.automation.execute.request",
handler: (message: BrowserAutomationExecuteRequest) => void,
): () => void;
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
}
export interface BrowserAutomationHandlerOptions {
client: BrowserAutomationClient;
serverId?: string;
getHost?: () => DesktopHostBridge | null;
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}
export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
return options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
request,
serverId: options.serverId,
ensureResidentBrowserWebview:
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
...(options.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
: {}),
...(options.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
: {}),
});
});
}
export function mountBrowserAutomationDaemonClientHandler(
client: unknown,
options?: { serverId?: string },
): () => void {
return mountBrowserAutomationHandler({
client: client as BrowserAutomationClient,
...(options?.serverId ? { serverId: options.serverId } : {}),
});
}
async function handleBrowserAutomationRequest(params: {
client: BrowserAutomationHandlerOptions["client"];
getHost: () => DesktopHostBridge | null;
request: BrowserAutomationExecuteRequest;
serverId?: string;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<void> {
const {
client,
getHost,
request,
serverId,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const browserHost = getHost()?.browser;
const executeAutomationCommand = browserHost?.executeAutomationCommand;
if (request.command.command === "new_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await openBrowserTabForRequest({
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
await rememberAgentBrowserTarget({ request, browserHost });
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
}),
});
return;
}
try {
const payload = await executeAutomationCommand(request);
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeBridgePayload(request.requestId, payload),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
}
async function openBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<BrowserAutomationResponsePayload> {
const {
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId ?? command.args.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
const url = command.args.url ?? "https://example.com";
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
kind: "browser",
browserId,
});
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
if (request.agentId) {
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
}
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
const registered = await waitForBrowserRegistration({
request,
browserId,
workspaceId,
executeAutomationCommand: browserHost.executeAutomationCommand,
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined
? { pollIntervalMs: registrationPollIntervalMs }
: {}),
});
if (!registered) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
retryable: true,
});
}
}
return {
requestId: request.requestId,
ok: true,
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
};
}
async function waitForBrowserRegistration(params: {
request: BrowserAutomationExecuteRequest;
browserId: string;
workspaceId: string;
executeAutomationCommand: (
request: BrowserAutomationExecuteRequest,
) => Promise<BrowserAutomationResponsePayload>;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
while (Date.now() < deadline) {
const payload = await params.executeAutomationCommand({
type: "browser.automation.execute.request",
requestId: `${params.request.requestId}:list_tabs`,
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
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)) {
return true;
}
}
await delay(params.pollIntervalMs ?? 100);
}
return false;
}
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,
): BrowserAutomationResponsePayload {
return { ...payload, requestId } as BrowserAutomationResponsePayload;
}
function normalizeThrownBridgeError(
requestId: string,
error: unknown,
): BrowserAutomationFailurePayload {
const typed = readTypedBrowserAutomationError(error);
if (typed) {
return browserAutomationFailure({ requestId, ...typed });
}
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered")) {
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Desktop browser automation failed.",
});
}
function readTypedBrowserAutomationError(
value: unknown,
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
return null;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return null;
}
return {
code: record.code as BrowserAutomationErrorCode,
message: record.message,
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
};
}
function browserAutomationFailure(params: {
requestId: string;
code: BrowserAutomationErrorCode;
message: string;
retryable?: boolean;
}): BrowserAutomationFailurePayload {
return {
requestId: params.requestId,
ok: false,
error: {
code: params.code,
message: params.message,
retryable: params.retryable ?? false,
},
};
}
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -21,6 +21,7 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -178,6 +179,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
desktopScrollContainer: {
flexShrink: 1,
minHeight: 0,
position: "relative",
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
@@ -451,6 +457,11 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/**
* Render the themed desktop-web scrollbar over the scroll area instead of the
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
*/
webScrollbar?: boolean;
}
export function AdaptiveModalSheet({
@@ -464,11 +475,16 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
webScrollbar = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const desktopScrollRef = useRef<ScrollView>(null);
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
enabled: webScrollbar && !isMobile,
});
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
@@ -574,13 +590,22 @@ export function AdaptiveModalSheet({
<>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
<View style={styles.desktopScrollContainer}>
<ScrollView
ref={desktopScrollRef}
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
onLayout={desktopScrollbar.onLayout}
onScroll={desktopScrollbar.onScroll}
onContentSizeChange={desktopScrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!webScrollbar}
>
{children}
</ScrollView>
{desktopScrollbar.overlay}
</View>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
describe("resident browser webviews", () => {
afterEach(() => {
clearResidentBrowserWebviewsForTests();
});
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
const host = document.createElement("div");
const webview = document.createElement("webview");
host.appendChild(webview);
document.body.appendChild(host);
releaseResidentBrowserWebview("browser-a", webview);
expect(host.children).toHaveLength(0);
expect(webview.isConnected).toBe(true);
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", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-agent",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expect(webview?.isConnected).toBe(true);
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com",
);
});
it("removes a resident webview when its browser tab closes", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-closed",
url: "https://example.com",
});
removeResidentBrowserWebview("browser-closed");
expect(webview?.isConnected).toBe(false);
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
});
});
@@ -0,0 +1,153 @@
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
interface BrowserWebviewElement extends HTMLElement {
src: string;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readDocument(): Document | null {
return typeof document === "undefined" ? null : document;
}
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) {
return existing;
}
const host = ownerDocument.createElement("div");
host.id = RESIDENT_BROWSER_HOST_ID;
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.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
return element as HTMLElement;
}
}
return null;
}
function applyResidentWebviewStyle(webview: HTMLElement): void {
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";
}
export function prepareBrowserWebview(
webview: HTMLElement,
input: { browserId: string; initialUrl?: string | null },
): void {
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
if (input.initialUrl) {
(webview as BrowserWebviewElement).src = input.initialUrl;
}
}
export function ensureResidentBrowserWebview(input: {
browserId: string;
url: string;
}): HTMLElement | null {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
return null;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return null;
}
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
return resident;
}
const existing = findBrowserWebview(browserId, ownerDocument);
if (existing) {
return existing;
}
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
releaseResidentBrowserWebview(browserId, webview);
return webview;
}
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (!webview) {
return null;
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
return webview;
}
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
webview.remove();
return;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return;
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview);
getResidentBrowserHost(ownerDocument).appendChild(webview);
}
export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
resident?.remove();
}
export function clearResidentBrowserWebviewsForTests(): void {
for (const webview of residentWebviewsByBrowserId.values()) {
webview.remove();
}
residentWebviewsByBrowserId.clear();
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}
@@ -80,6 +80,8 @@ interface CombinedModelSelectorProps {
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
@@ -87,6 +89,15 @@ interface CombinedModelSelectorProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
/**
* Render the custom trigger as a full-width form field: the outer Pressable
* becomes a transparent passthrough that stretches its child edge-to-edge and
* stops painting its own hover/pressed background and rounded corners. The
* trigger itself owns the field visuals and reads hovered/pressed to show its
* active state. Without this the trigger stays a content-width toolbar chip
* (the composer's layout).
*/
triggerFill?: boolean;
}
interface SelectorContentProps {
@@ -574,6 +585,7 @@ export function CombinedModelSelector({
isRetryingProvider = false,
disabled = false,
serverId = null,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -693,14 +705,26 @@ export function CombinedModelSelector({
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
],
[disabled, isOpen, renderTrigger],
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
// Fill mode: transparent full-width passthrough. The trigger paints its own
// hover/pressed state from the args, so the wrapper must not double-paint.
if (triggerFill) {
return [
styles.trigger,
styles.customTriggerWrapper,
styles.triggerFill,
disabled && styles.triggerDisabled,
];
}
return [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
];
},
[disabled, isOpen, renderTrigger, triggerFill],
);
const handleBackToAll = useCallback(() => {
@@ -789,12 +813,16 @@ export function CombinedModelSelector({
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
})}
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
hovered: Boolean(hovered),
pressed,
})
}
</Pressable>
) : (
<ComboboxTrigger
@@ -886,6 +914,16 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
height: "auto",
},
// Stretch the wrapper (and, via column + stretch, its single child) to the
// full width of the field, with no background or rounding of its own.
triggerFill: {
alignSelf: "stretch",
flexShrink: 0,
flexDirection: "column",
alignItems: "stretch",
backgroundColor: "transparent",
borderRadius: 0,
},
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
+12 -4
View File
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHosts } from "@/runtime/host-runtime";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent;
showHost: boolean;
}
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent}>
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
}
function AgentItemsSection({
@@ -257,6 +261,7 @@ function AgentItemsSection({
onSelect,
sectionDividerStyle,
sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
@@ -277,7 +282,7 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
);
})}
@@ -302,6 +307,8 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
// Host names only earn their space once results can span more than one host.
const showHost = useHosts().length > 1;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
@@ -477,6 +484,7 @@ export function CommandCenter() {
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : null}
</>
@@ -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>
);
}
+49 -1
View File
@@ -1,5 +1,15 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
import {
CalendarClock,
FolderPlus,
History,
Home,
Plus,
Search,
Server,
Settings,
X,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -56,6 +66,7 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
@@ -105,6 +116,7 @@ interface SidebarLabels {
switchHost: string;
searchHosts: string;
sessions: string;
schedules: string;
closeSidebar: string;
}
@@ -114,12 +126,14 @@ interface MobileSidebarProps extends SidebarSharedProps {
isOpen: boolean;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({
@@ -221,6 +235,10 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSessionsRoute());
}, []);
const handleViewSchedulesNavigate = useCallback(() => {
router.push(buildSchedulesRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
@@ -231,6 +249,7 @@ export const LeftSidebar = memo(function LeftSidebar({
switchHost: t("sidebar.host.switchTitle"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
schedules: t("sidebar.sections.schedules"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
@@ -267,6 +286,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
);
}
@@ -282,6 +302,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
);
});
@@ -557,9 +578,11 @@ function MobileSidebar({
isOpen,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const {
translateX,
backdropOpacity,
@@ -587,6 +610,13 @@ function MobileSidebar({
handleViewMoreNavigate();
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
const handleViewSchedules = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewSchedulesNavigate();
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
@@ -747,6 +777,14 @@ function MobileSidebar({
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
<WorkspacesSectionHeader />
<Pressable
@@ -827,9 +865,11 @@ function DesktopSidebar({
insetsTop,
isOpen,
handleViewMore,
handleViewSchedules,
}: DesktopSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -909,6 +949,14 @@ function DesktopSidebar({
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
</View>
<WorkspacesSectionHeader />
@@ -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,11 +13,23 @@ 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", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
it("falls back to the bot icon for unknown custom providers", () => {
@@ -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}`}
@@ -92,30 +92,49 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
// Static left-side control: square for multi-select, circle for single-select.
// Always rendered so toggling only swaps fill/border — the row never reflows.
const controlStyle = useMemo(
() => [
styles.selectionControl,
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
{
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
},
],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
);
const radioDotStyle = useMemo(
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
[theme.colors.accent],
);
return (
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityRole={multiSelect ? "checkbox" : "radio"}
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
aria-checked={isSelected}
>
<View style={styles.optionItemContent}>
<View style={controlStyle}>
{isSelected && multiSelect ? (
<Check size={12} color={theme.colors.accentForeground} />
) : null}
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
</View>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
{option.description ? (
<Text style={optionDescriptionStyle}>{option.description}</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
@@ -124,7 +143,9 @@ function QuestionOptionRow({
interface QuestionNavButtonProps {
index: number;
total: number;
header: string;
isActive: boolean;
isAnswered: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
@@ -132,7 +153,9 @@ interface QuestionNavButtonProps {
function QuestionNavButton({
index,
total,
header,
isActive,
isAnswered,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
@@ -171,7 +194,7 @@ function QuestionNavButton({
return (
<Pressable
accessibilityRole="button"
accessibilityRole="tab"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
@@ -180,11 +203,61 @@ function QuestionNavButton({
onPress={handlePress}
disabled={isResponding}
>
<Text style={textStyle}>{index + 1}</Text>
{isAnswered ? (
<Check
size={12}
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : null}
<Text style={textStyle} numberOfLines={1}>
{header}
</Text>
</Pressable>
);
}
interface QuestionNavProps {
questions: QuestionFormQuestion[];
activeIndex: number;
isAnswered: (qIndex: number) => boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
// Titled tabs (one per question header) with a check on answered ones. Hidden for
// a lone question — a single "1 of 1" tab carries no information.
function QuestionNav({
questions,
activeIndex,
isAnswered,
isResponding,
onSelect,
}: QuestionNavProps) {
if (questions.length <= 1) {
return null;
}
return (
<View
style={styles.questionNav}
testID="question-form-question-nav"
accessibilityRole="tablist"
>
{questions.map((question, qIndex) => (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
header={question.header}
isActive={qIndex === activeIndex}
isAnswered={isAnswered(qIndex)}
isResponding={isResponding}
onSelect={onSelect}
/>
))}
</View>
);
}
interface QuestionOtherInputProps {
qIndex: number;
accessibilityLabel: string;
@@ -355,6 +428,12 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
setActiveQuestionIndex(index);
}, []);
const navIsAnswered = useCallback(
(qIndex: number) =>
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
[questions, selections, otherTexts],
);
const handlePrimaryAction = useCallback(() => {
if (!isLastQuestion) {
if (!activeQuestionAnswered || isResponding) return;
@@ -407,9 +486,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
// Single-select radios need a group; checkboxes are valid standalone.
const optionsGroupAccessibility = useMemo(
() =>
activeQuestion && !activeQuestion.multiSelect
? ({
accessibilityRole: "radiogroup",
accessibilityLabel: activeQuestion.question,
} as const)
: {},
[activeQuestion],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
@@ -436,33 +522,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return (
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
);
})}
</View>
<QuestionNav
questions={questions}
activeIndex={resolvedActiveQuestionIndex}
isAnswered={navIsAnswered}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
@@ -546,12 +622,6 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
@@ -571,24 +641,24 @@ const styles = StyleSheet.create((theme) => ({
},
questionNav: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
},
questionNavButton: {
minWidth: 28,
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
gap: theme.spacing[1],
minHeight: 28,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.xs,
fontWeight: "700",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
optionItem: {
flexDirection: "row",
@@ -603,25 +673,40 @@ const styles = StyleSheet.create((theme) => ({
optionItemContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
gap: theme.spacing[2],
},
optionTextBlock: {
flex: 1,
gap: 2,
gap: theme.spacing[1],
},
optionLabel: {
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
lineHeight: 22,
},
optionDescription: {
fontSize: theme.fontSize.xs,
lineHeight: 16,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
optionCheckSlot: {
width: 16,
selectionControl: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
marginLeft: "auto",
borderWidth: theme.borderWidth[1],
marginTop: 2, // optical-align 18px control to the 22px label first line
},
selectionControlCheckbox: {
borderRadius: theme.borderRadius.base,
},
selectionControlRadio: {
borderRadius: 999,
},
selectionRadioDot: {
width: 8,
height: 8,
borderRadius: 999,
},
otherInput: {
borderWidth: 1,
@@ -0,0 +1,375 @@
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
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,
type IntervalUnit,
partsToEveryMs,
validateCron,
} from "@/utils/schedule-format";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
type CadenceMode = ScheduleCadence["type"];
interface CronPreset {
label: string;
expression: string;
}
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
// through describeCron() so the chip and the live preview agree.
const CRON_PRESETS: CronPreset[] = [
{ label: "Every hour", expression: "0 * * * *" },
{ label: "Daily 9:00", expression: "0 9 * * *" },
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
];
const MODE_OPTIONS = [
{ value: "every" as const, label: "Interval" },
{ value: "cron" as const, label: "Cron" },
];
const UNIT_OPTIONS = [
{ value: "minutes" as const, label: "Minutes" },
{ value: "hours" as const, label: "Hours" },
{ value: "days" as const, label: "Days" },
];
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
const UNIT_NOUN: Record<IntervalUnit, string> = {
minutes: "minute",
hours: "hour",
days: "day",
};
function describeInterval(value: number, unit: IntervalUnit): string {
const noun = UNIT_NOUN[unit];
if (value === 1) {
return `Runs every ${noun}`;
}
return `Runs every ${value} ${noun}s`;
}
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
}
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
const mode = value.type;
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
// once from the incoming cadence via lazy state initializers and bump
// resetKey only when we change the content ourselves (mode switch, preset
// chip) — never on every keystroke.
const [intervalValueText, setIntervalValueText] = useState(() =>
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
);
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
);
const [cronText, setCronText] = useState(() =>
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
// Remember the cron expression the user had so toggling Interval -> Cron and
// back does not discard a blanked-out field. Interval mode rebuilds straight
// from the live numeric value + unit, so it needs no equivalent ref.
const lastCronExpression = useRef(
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
const parsedIntervalValue = useMemo(() => {
const parsed = Number.parseInt(intervalValueText, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}, [intervalValueText]);
const emitInterval = useCallback(
(rawValue: number, unit: IntervalUnit) => {
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
},
[onChange],
);
const emitCron = useCallback(
(expression: string) => {
lastCronExpression.current = expression;
onChange({ type: "cron", expression });
},
[onChange],
);
const handleModeChange = useCallback(
(nextMode: CadenceMode) => {
if (nextMode === mode) {
return;
}
if (nextMode === "every") {
emitInterval(parsedIntervalValue, intervalUnit);
} else {
emitCron(cronText.trim() || lastCronExpression.current);
}
},
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
);
const handleIntervalValueChange = useCallback(
(text: string) => {
// Keep only digits so the cadence stays a positive integer count.
const digits = text.replace(/[^0-9]/g, "");
setIntervalValueText(digits);
const parsed = Number.parseInt(digits, 10);
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
},
[emitInterval, intervalUnit],
);
const handleUnitChange = useCallback(
(unit: IntervalUnit) => {
setIntervalUnit(unit);
emitInterval(parsedIntervalValue, unit);
},
[emitInterval, parsedIntervalValue],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
emitCron(text.trim());
},
[emitCron],
);
const handlePresetPress = useCallback(
(expression: string) => {
setCronText(expression);
bumpFieldResetKey();
emitCron(expression);
},
[emitCron],
);
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
const trimmedCron = cronText.trim();
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
let cronFeedback: ReactNode = null;
if (cronError) {
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
} else if (cronPreview) {
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
}
return (
<View style={styles.container}>
<SegmentedControl
size="sm"
value={mode}
onValueChange={handleModeChange}
options={MODE_OPTIONS}
style={styles.modeControl}
testID="cadence-mode"
/>
{mode === "every" ? (
<View style={styles.section}>
<View style={styles.intervalRow}>
<AdaptiveTextInput
testID="cadence-interval-value"
accessibilityLabel="Interval value"
initialValue={intervalValueText}
resetKey={`cadence-interval-${fieldResetKey}`}
value={intervalValueText}
onChangeText={handleIntervalValueChange}
keyboardType="number-pad"
style={styles.intervalInput}
/>
<SegmentedControl
size="sm"
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
style={styles.unitControl}
testID="cadence-interval-unit"
/>
</View>
<Text style={styles.preview}>{intervalPreview}</Text>
</View>
) : (
<View style={styles.section}>
<View style={styles.presetRow}>
{CRON_PRESETS.map((preset) => (
<CronPresetChip
key={preset.expression}
label={preset.label}
expression={preset.expression}
isSelected={trimmedCron === preset.expression}
onSelect={handlePresetPress}
/>
))}
</View>
<AdaptiveTextInput
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{cronFeedback}
<Text style={styles.hint}>Times are in UTC</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
function CronPresetChip({
label,
expression,
isSelected,
onSelect,
}: {
label: string;
expression: string;
isSelected: boolean;
onSelect: (expression: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(expression);
}, [onSelect, expression]);
const chipStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.chip,
isSelected && styles.chipSelected,
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
],
[isSelected],
);
const labelStyle = useMemo(
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
[isSelected],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
section: {
gap: theme.spacing[3],
},
intervalRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
intervalInput: {
width: 88,
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// 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",
flexWrap: "wrap",
gap: theme.spacing[2],
},
chip: {
minHeight: 32,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
chipHover: {
backgroundColor: theme.colors.surface3,
},
chipSelected: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.accentForeground,
},
cronInput: {
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: MONOSPACE_FONT,
},
preview: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hint: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
}));
@@ -0,0 +1,867 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Folder } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { CombinedModelSelector } from "@/components/combined-model-selector";
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 { useProjects } from "@/hooks/use-projects";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
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;
visible: boolean;
onClose: () => void;
mode: "create" | "edit";
schedule?: ScheduleSummary;
}
interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
}
// The model/cwd config only exists on new-agent schedules; this screen filters
// to that target, but guard anyway so prefill stays type-safe.
function newAgentConfig(schedule: ScheduleSummary | undefined) {
if (schedule && schedule.target.type === "new-agent") {
return schedule.target.config;
}
return null;
}
function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialValues | undefined {
const config = newAgentConfig(schedule);
if (!config) {
return undefined;
}
return {
provider: config.provider as AgentProvider,
model: config.model ?? null,
modeId: config.modeId ?? null,
workingDir: config.cwd,
};
}
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: 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 };
}
function resolveSelectedScheduleProjectTarget(input: {
targets: readonly ScheduleProjectTarget[];
serverId: string | null;
cwd: string;
}): ScheduleProjectTarget | null {
const cwd = input.cwd.trim();
if (!input.serverId || !cwd) {
return null;
}
return (
input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null
);
}
function isSelectedModelValidForProviders(input: {
providers: ProviderSelectorProvider[];
selectedProvider: AgentProvider | null;
selectedModel: string;
}): boolean {
if (!input.selectedProvider) {
return false;
}
const provider = input.providers.find((entry) => entry.id === input.selectedProvider);
if (!provider || provider.modelSelection.kind !== "models") {
return false;
}
const selectedModel = input.selectedModel.trim();
if (!selectedModel) {
return true;
}
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
export function ScheduleFormSheet({
serverId,
visible,
onClose,
mode,
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
const editConfig = newAgentConfig(schedule);
const { projects } = useProjects();
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
);
const initialValues = useMemo(
() => (isEdit ? buildInitialValues(schedule) : undefined),
[isEdit, schedule],
);
// isCreateFlow drives useAgentFormState's RESOLVE pass that applies
// initialValues. We want that for edit too (to prefill the picker fields from
// the schedule's config), so this stays true in both modes: the form is
// always a "fill these fields" flow, seeded either from preferences (create)
// or from the schedule (edit).
const form = useAgentFormState({
initialServerId: serverId ?? null,
initialValues,
isVisible: visible,
isCreateFlow: true,
onlineServerIds,
});
const {
selectedServerId,
selectedProvider,
selectedModel,
selectedMode,
selectedThinkingOptionId,
workingDir,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
isAllModelsLoading,
persistFormPreferences,
} = form;
const selectedProjectTarget = useMemo(
() =>
resolveSelectedScheduleProjectTarget({
targets: projectOptions.targets,
serverId: selectedServerId,
cwd: workingDir,
}),
[projectOptions.targets, selectedServerId, workingDir],
);
const selectedProjectOptionId = selectedProjectTarget?.optionId ?? "";
const mutationServerId = selectedProjectTarget?.serverId ?? selectedServerId ?? serverId ?? "";
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
setWorkingDirFromUser(target.cwd);
},
[
clearProviderSelectionFromUser,
selectedProjectTarget,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
);
// One nested control selects provider -> model (the draft screen's selector).
// Render it as a full-width field that leads with the provider glyph and mutes
// its placeholder, matching the working-directory field.
const renderModelTrigger = useCallback(
({
selectedModelLabel,
disabled,
isOpen,
hovered,
pressed,
}: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}): ReactNode => (
<ModelTrigger
label={selectedModelLabel}
provider={selectedProvider}
disabled={disabled}
active={hovered || pressed || isOpen}
isPlaceholder={!selectedModel}
/>
),
[selectedModel, selectedProvider],
);
const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({
serverId: mutationServerId,
});
const isSubmitting = isCreating || isUpdating;
// Name / prompt / cadence / maxRuns are local to this form, not part of
// useAgentFormState. Seed once per open from the schedule being edited.
const [name, setName] = useState(() => schedule?.name ?? "");
const [prompt, setPrompt] = useState(() => schedule?.prompt ?? "");
const [maxRuns, setMaxRuns] = useState(() =>
schedule?.maxRuns != null ? String(schedule.maxRuns) : "",
);
const [cadence, setCadence] = useState<ScheduleCadence>(
() => schedule?.cadence ?? DEFAULT_CADENCE,
);
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldResetKey, setFieldResetKey] = useState(0);
// The sheet stays mounted across opens, so the lazy initializers above only
// run once. Re-seed the locally-owned fields (name/prompt/cadence/maxRuns)
// each time the sheet transitions closed -> open; the picker fields are
// re-seeded by useAgentFormState from initialValues on the same flip.
const wasVisibleRef = useRef(false);
useEffect(() => {
if (visible && !wasVisibleRef.current) {
setName(schedule?.name ?? "");
setPrompt(schedule?.prompt ?? "");
setMaxRuns(schedule?.maxRuns != null ? String(schedule.maxRuns) : "");
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
}
wasVisibleRef.current = visible;
}, [visible, schedule]);
const promptTrimmed = prompt.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit =
promptTrimmed.length > 0 &&
selectedModelIsValid &&
Boolean(selectedProjectTarget) &&
cadenceError === null &&
!isSubmitting;
const handleSubmit = useCallback(async () => {
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
return;
}
setSubmitError(null);
try {
await persistFormPreferences();
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
const maxRunsValue =
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
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));
}
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
onClose,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProjectTarget,
selectedProvider,
selectedThinkingOptionId,
updateSchedule,
]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
const header = useMemo<SheetHeader>(
() => ({ title: isEdit ? "Edit schedule" : "New schedule" }),
[isEdit],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
style={styles.footerButton}
variant="secondary"
onPress={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
style={styles.footerButton}
variant="default"
onPress={handleSubmitPress}
disabled={!canSubmit}
loading={isSubmitting}
testID="schedule-form-submit"
>
{isEdit ? "Save changes" : "Create schedule"}
</Button>
</View>
),
[canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
footer={footer}
webScrollbar
testID="schedule-form-sheet"
>
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<AdaptiveTextInput
testID="schedule-name-input"
accessibilityLabel="Schedule name"
initialValue={name}
resetKey={`schedule-name-${fieldResetKey}`}
value={name}
onChangeText={setName}
placeholder="Optional"
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Prompt</Text>
<AdaptiveTextInput
testID="schedule-prompt-input"
accessibilityLabel="Prompt"
initialValue={prompt}
resetKey={`schedule-prompt-${fieldResetKey}`}
value={prompt}
onChangeText={setPrompt}
placeholder="What should the agent do each run?"
style={styles.multilineInput}
multiline
numberOfLines={4}
textAlignVertical="top"
/>
</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>
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
<CadenceEditor value={cadence} onChange={setCadence} error={cadenceError ?? undefined} />
</View>
<View style={styles.field}>
<Text style={styles.label}>Max runs</Text>
<AdaptiveTextInput
testID="schedule-max-runs-input"
accessibilityLabel="Max runs"
initialValue={maxRuns}
resetKey={`schedule-max-runs-${fieldResetKey}`}
value={maxRuns}
onChangeText={setMaxRuns}
placeholder="Unlimited"
style={styles.input}
keyboardType="number-pad"
/>
<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>
);
}
// ---------------------------------------------------------------------------
// Mode field - Combobox over the selected provider's modes.
// ---------------------------------------------------------------------------
function ModeField({
options,
selectedMode,
onSelect,
}: {
options: { id: string; label: string }[];
selectedMode: string;
onSelect: (modeId: string) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((option) => ({ id: option.id, label: option.label })),
[options],
);
const selectedLabel =
options.find((option) => option.id === selectedMode)?.label ?? "Default mode";
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
setOpen(false);
},
[onSelect],
);
const handlePress = useCallback(() => {
setOpen((current) => !current);
}, []);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.selectTrigger,
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
],
[open],
);
return (
<View style={styles.field}>
<Text style={styles.label}>Mode</Text>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handlePress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select mode (${selectedLabel})`}
testID="schedule-mode-trigger"
>
<Text style={styles.selectTriggerText} numberOfLines={1}>
{selectedLabel}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</Pressable>
</View>
<Combobox
options={comboboxOptions}
value={selectedMode}
onSelect={handleSelect}
searchable={comboboxOptions.length > 6}
title="Select mode"
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
/>
</View>
);
}
function ProjectField({
options,
targetByOptionId,
value,
selectedTarget,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
const target = targetByOptionId.get(id);
if (!target) {
return;
}
onSelect(target);
setOpen(false);
},
[onSelect, targetByOptionId],
);
const handlePress = useCallback(() => {
setOpen((current) => !current);
}, []);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.selectTrigger,
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
],
[open],
);
const displayValue = selectedTarget?.projectName ?? "Select project";
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<ProjectOptionItem option={option} selected={selected} active={active} onPress={onPress} />
),
[],
);
return (
<>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handlePress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select project (${displayValue})`}
testID="schedule-project-trigger"
>
<Text
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
numberOfLines={1}
>
{displayValue}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</Pressable>
</View>
{description ? <Text style={styles.hint}>{description}</Text> : null}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
searchable
searchPlaceholder="Search projects..."
emptyText="No projects found"
title="Select project"
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
renderOption={renderOption}
/>
</>
);
}
function ProjectOptionItem({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement {
const leadingSlot = useMemo(
() => (
<View style={styles.optionIconBox}>
<Folder size={16} color={styles.chevron.color} />
</View>
),
[],
);
return (
<ComboboxItem
testID={buildProjectOptionTestId(option.id)}
label={option.label}
description={option.description}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
// ---------------------------------------------------------------------------
// Shared bits
// ---------------------------------------------------------------------------
/** Dynamic provider glyph - reads its color off a StyleSheet object so the
* runtime-resolved component stays compliant without useUnistyles. */
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider);
return <Icon size={16} color={styles.providerIcon.color} />;
}
// Non-interactive field rendered inside CombinedModelSelector's trigger (with
// triggerFill). The selector's outer Pressable owns press/hover; this leaf just
// paints the field and reads `active` for the focus border.
function ModelTrigger({
label,
provider,
disabled,
active,
isPlaceholder,
}: {
label: string;
provider: string | null;
disabled: boolean;
active: boolean;
isPlaceholder: boolean;
}): ReactElement {
const containerStyle = useMemo(
() => [
styles.selectTrigger,
active && styles.selectTriggerActive,
disabled && styles.selectTriggerDisabled,
],
[active, disabled],
);
return (
<View pointerEvents="none" style={containerStyle} testID="schedule-model-trigger">
<ProviderGlyph provider={provider} />
<Text
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{label}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
multilineInput: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
minHeight: 96,
},
hint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTriggerActive: {
borderColor: theme.colors.borderAccent,
},
selectTriggerDisabled: {
opacity: theme.opacity[50],
},
selectTriggerText: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
selectTriggerPlaceholder: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
optionIconBox: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
},
footer: {
flex: 1,
flexDirection: "row",
gap: theme.spacing[3],
},
footerButton: {
flex: 1,
},
// Static color holders read by the dynamic provider icon + chevron (compliant
// idiom - no useUnistyles in render).
providerIcon: {
color: theme.colors.foregroundMuted,
},
chevron: {
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,339 @@
import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native";
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { getProviderIcon } from "@/components/provider-icons";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
// change (never call useUnistyles in render). See docs/unistyles.md.
const ThemedPencil = withUnistyles(Pencil);
const ThemedPause = withUnistyles(Pause);
const ThemedPlay = withUnistyles(Play);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedKebab = withUnistyles(MoreVertical);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const MENU_ICON_SIZE = 14;
const PROVIDER_ICON_SIZE = 16;
// Pending flags for each action so the parent table can wire a mutation hook
// and the row reflects in-flight state without owning the mutation itself.
export interface ScheduleRowPending {
pause?: boolean;
resume?: boolean;
runNow?: boolean;
delete?: boolean;
}
export interface ScheduleRowActions {
onEdit: () => void;
onPause: () => void;
onResume: () => void;
onRunNow: () => void;
onDelete: () => void;
}
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function resolveProvider(schedule: ScheduleSummary): string | null {
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
}
function resolveModelLabel(schedule: ScheduleSummary): string {
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
return schedule.target.config.model;
}
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";
}
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
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider);
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
}
/**
* One schedule, rendered as a settings-style card row: provider glyph + title,
* a muted secondary line (model · cadence · next run), a StatusBadge, and the
* kebab menu that owns every row action. Tapping the row opens the editor.
*
* Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns
* press, the nested kebab Pressable never fights it, and the row background
* highlights without reflow.
*/
export function ScheduleRow({
schedule,
pending,
isFirst,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: ScheduleRowProps): ReactElement {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const provider = resolveProvider(schedule);
const title = resolveScheduleTitle(schedule);
const meta = [
resolveModelLabel(schedule),
formatCadence(schedule.cadence),
nextRunLabel(schedule),
]
.filter(Boolean)
.join(" · ");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
settingsStyles.row,
styles.row,
!isFirst && settingsStyles.rowBorder,
isHovered && !isCompact && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isHovered, isCompact],
);
return (
<View
style={styles.rowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
style={rowStyle}
onPress={onEdit}
accessibilityRole="button"
accessibilityLabel={`Edit schedule ${title}`}
testID={`schedule-row-${schedule.id}`}
>
<View style={styles.main}>
<View style={styles.leading}>
<ProviderGlyph provider={provider} />
</View>
<View style={styles.textGroup}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
</View>
</View>
<View style={styles.trailing}>
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<ScheduleKebabMenu
schedule={schedule}
pending={pending}
onEdit={onEdit}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
onDelete={onDelete}
/>
</View>
</Pressable>
</View>
);
}
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
return (
<ThemedKebab
size={MENU_ICON_SIZE}
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
/>
);
}
function ScheduleKebabMenu({
schedule,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityRole={isNative ? "button" : undefined}
accessibilityLabel="Schedule actions"
testID={`schedule-kebab-${schedule.id}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
leading={editLeading}
onSelect={onEdit}
testID={`schedule-menu-edit-${schedule.id}`}
>
Edit schedule
</DropdownMenuItem>
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed"}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
)}
<DropdownMenuItem
leading={runLeading}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={deleteLeading}
destructive
status={pending?.delete ? "pending" : "idle"}
pendingLabel="Deleting..."
onSelect={onDelete}
testID={`schedule-menu-delete-${schedule.id}`}
>
Delete schedule
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabTriggerStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered];
}
const styles = StyleSheet.create((theme) => ({
// Static color holder for the dynamic provider icon (compliant idiom).
providerIcon: {
color: theme.colors.foregroundMuted,
},
rowContainer: {
position: "relative",
},
row: {
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
main: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
leading: {
width: PROVIDER_ICON_SIZE,
height: PROVIDER_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
textGroup: {
flex: 1,
minWidth: 0,
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
kebabTrigger: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
kebabTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,151 @@
import { useCallback, useState, type ReactElement } from "react";
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 { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
interface SchedulesTableProps {
serverId: string;
schedules: ScheduleSummary[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the header's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: ScheduleSummary) => void;
}
/**
* 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({
serverId,
schedules,
onEditSchedule,
}: SchedulesTableProps): ReactElement {
const mutations = useScheduleMutations({ serverId });
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{schedules.map((schedule, index) => (
<SchedulesTableRow
key={schedule.id}
schedule={schedule}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// 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({
schedule,
isFirst,
mutations,
onEditSchedule,
}: {
schedule: ScheduleSummary;
isFirst: boolean;
mutations: ScheduleMutations;
onEditSchedule: (schedule: ScheduleSummary) => void;
}): ReactElement {
const { id } = schedule;
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
async (key: keyof ScheduleRowPending, action: () => Promise<void>): Promise<void> => {
setPending((current) => ({ ...current, [key]: true }));
try {
await action();
} catch {
// Mutations roll back their own optimistic cache writes on error and
// re-fetch on settle; surfacing per-row toasts here is out of scope.
} finally {
setPending((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
},
[],
);
const handleEdit = useCallback(() => {
onEditSchedule(schedule);
}, [onEditSchedule, schedule]);
const handlePause = useCallback(() => {
void runAction("pause", () => mutations.pauseSchedule(id));
}, [runAction, mutations, id]);
const handleResume = useCallback(() => {
void runAction("resume", () => mutations.resumeSchedule(id));
}, [runAction, mutations, id]);
const handleRunNow = useCallback(() => {
void runAction("runNow", () => mutations.runScheduleNow(id));
}, [runAction, mutations, id]);
const handleDelete = useCallback(() => {
void (async () => {
const confirmed = await confirmDialog({
title: "Delete schedule",
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
});
if (!confirmed) {
return;
}
await runAction("delete", () => mutations.deleteSchedule(id));
})();
}, [runAction, mutations, id, schedule]);
return (
<ScheduleRow
schedule={schedule}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}
onPause={handlePause}
onResume={handleResume}
onRunNow={handleRunNow}
onDelete={handleDelete}
/>
);
}
const CONTENT_MAX_WIDTH = 720;
const styles = StyleSheet.create((theme) => ({
// Center the card in a readable column, matching settings and projects.
listContent: {
width: "100%",
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
},
}));
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
return (
<>
@@ -1749,14 +1749,13 @@ function WorkspaceRowItem({
isDragging = false,
dragHandleProps,
}: WorkspaceRowItemProps) {
const currentPathname = usePathname();
const handlePress = useCallback(() => {
if (!workspace.serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return (
<WorkspaceRow
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
@@ -10,11 +10,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
selected,
onToggle,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
@@ -1,5 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react";
import { usePathname } from "expo-router";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -334,7 +333,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const currentPathname = usePathname();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
@@ -342,8 +340,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => {
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
act(() => {
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
useSidebarViewStore.getState().toggleHostFilter("host-b");
});
await act(async () => {
@@ -26,6 +26,9 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
}
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (workspaceAttachment) {
agentAttachments.push(workspaceAttachment);
@@ -11,6 +11,8 @@ 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,
});
}
if (isPullRequestContextAttachment(attachment)) {
+15 -6
View File
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.159.1",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.159.1", "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.0",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.0", "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.21",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",
@@ -335,6 +335,15 @@ const CATALOG_DATA = [
installLink: "https://stakpak.dev/",
command: ["stakpak", "acp"],
},
{
id: "traecli",
title: "TRAE CLI",
description: "ByteDance's official TRAE coding agent with native ACP support",
version: "manual",
iconId: "traecli",
installLink: "https://docs.trae.cn/cli",
command: ["traecli", "acp", "serve"],
},
{
id: "vtcode",
title: "VT Code",
+26 -1
View File
@@ -1,5 +1,15 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
export type DesktopNotificationPermission = "granted" | "denied" | "default";
@@ -117,9 +127,24 @@ export interface DesktopBrowserNewTabRequestEvent {
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
setWorkspaceActiveBrowser?: (input: {
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?: (
request: BrowserAutomationExecuteRequest,
) => Promise<BrowserAutomationExecuteResponse["payload"]>;
/** Capture a PNG screenshot of the guest viewport cropped to `rect`. */
captureElement?: (
browserId: string,
rect: { x: number; y: number; width: number; height: number },
) => Promise<string | null>;
/** Copy element text and/or an image to the system clipboard from main. */
copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>;
}
export interface DesktopInvokeBridge {
@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
});
});
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
it("reports the found update while it is still preparing", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
availableUpdate: null,
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
});
});
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
).toBe("Update ready: v1.2.3");
});
it("keeps manual check feedback visible while an update is pending", () => {
it("shows the found version while an update is pending", () => {
expect(
formatStatusText({
status: "pending",
availableUpdate: null,
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
});
it("keeps manual check feedback visible when an update is available", () => {
@@ -137,6 +137,18 @@ export function formatStatusText(input: {
}
if (status === "pending") {
if (availableUpdate?.latestVersion) {
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.pendingWithVersionAndLastChecked"
: "desktop.updates.status.pendingWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
nextAvailable = result;
} else if (result.hasUpdate) {
nextStatus = "pending";
nextAvailable = null;
nextAvailable = result;
} else {
nextStatus = "up-to-date";
nextAvailable = null;
@@ -43,6 +43,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
expect(findProvider("traecli").command).toEqual(["traecli", "acp", "serve"]);
});
it("maps a catalog entry to the daemon provider config patch", () => {
@@ -76,6 +76,7 @@ export interface UseAgentFormStateResult {
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
clearProviderSelectionFromUser: () => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
}
@@ -404,6 +405,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
],
);
const clearProviderSelectionFromUser = useCallback(() => {
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, []);
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
@@ -547,6 +552,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
}),
@@ -581,6 +587,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
],
+2 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -136,7 +136,6 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -223,10 +222,9 @@ export function useCommandCenter() {
navigateToAgent({
serverId: agent.serverId,
agentId: agent.id,
currentPathname: pathname,
});
},
[pathname, setOpen],
[setOpen],
);
const openProjectPicker = useOpenProjectPicker();
@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
navigateToWorkspace(action.serverId, action.workspaceId);
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
@@ -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,
};
}
+12 -2
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import type { ProjectSummary } from "@/utils/projects";
@@ -16,6 +16,10 @@ export type {
export const projectsQueryKey = ["projects"] as const;
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
return hosts.map((host) => host.serverId).join("|");
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
@@ -27,6 +31,11 @@ export interface UseProjectsResult {
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const hostInputs = useMemo<ProjectsHostInput[]>(
() =>
hosts.map((host) => ({
@@ -37,8 +46,9 @@ export function useProjects(): UseProjectsResult {
);
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
staleTime: 5_000,
});
return {
@@ -0,0 +1,277 @@
import { useCallback } from "react";
import {
useMutation,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
CreateScheduleOptions,
DaemonClient,
UpdateScheduleOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
export interface UseScheduleMutationsResult {
createSchedule: (input: CreateScheduleInput) => Promise<void>;
updateSchedule: (input: UpdateScheduleInput) => Promise<void>;
pauseSchedule: (id: string) => Promise<void>;
resumeSchedule: (id: string) => Promise<void>;
deleteSchedule: (id: string) => Promise<void>;
runScheduleNow: (id: string) => Promise<void>;
isCreating: boolean;
isUpdating: boolean;
isPausing: boolean;
isResuming: boolean;
isDeleting: boolean;
isRunningNow: boolean;
}
interface ScheduleListSnapshot {
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
}
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
if (!client) {
throw new Error(unavailableMessage);
}
return client;
}
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
return {
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
queryKey: schedulesQueryBaseKey,
}),
};
}
function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapshot): void {
for (const [queryKey, previous] of snapshot.previous) {
queryClient.setQueryData(queryKey, previous);
}
}
function updateScheduleSections(
queryClient: QueryClient,
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
(current) => {
if (!current) {
return current;
}
return { sections: current.sections.map(updateSection) };
},
);
}
function optimisticallySetStatus(
queryClient: QueryClient,
serverId: string,
id: string,
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
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 {
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
);
}
export function useScheduleMutations({
serverId,
}: {
serverId: string;
}): UseScheduleMutationsResult {
const queryClient = useQueryClient();
const { t } = useTranslation();
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}, [queryClient]);
const createMutation = useMutation({
mutationFn: async (input: CreateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleCreate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const updateMutation = useMutation({
mutationFn: async (input: UpdateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleUpdate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const pauseMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.schedulePause({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "paused");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const resumeMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleResume({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "active");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const deleteMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallyRemove(queryClient, serverId, id);
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const runNowMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleRunOnce({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const createSchedule = useCallback(
async (input: CreateScheduleInput): Promise<void> => {
await createMutation.mutateAsync(input);
},
[createMutation],
);
const updateSchedule = useCallback(
async (input: UpdateScheduleInput): Promise<void> => {
await updateMutation.mutateAsync(input);
},
[updateMutation],
);
const pauseSchedule = useCallback(
async (id: string): Promise<void> => {
await pauseMutation.mutateAsync(id);
},
[pauseMutation],
);
const resumeSchedule = useCallback(
async (id: string): Promise<void> => {
await resumeMutation.mutateAsync(id);
},
[resumeMutation],
);
const deleteSchedule = useCallback(
async (id: string): Promise<void> => {
await deleteMutation.mutateAsync(id);
},
[deleteMutation],
);
const runScheduleNow = useCallback(
async (id: string): Promise<void> => {
await runNowMutation.mutateAsync(id);
},
[runNowMutation],
);
return {
createSchedule,
updateSchedule,
pauseSchedule,
resumeSchedule,
deleteSchedule,
runScheduleNow,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isPausing: pauseMutation.isPending,
isResuming: resumeMutation.isPending,
isDeleting: deleteMutation.isPending,
isRunningNow: runNowMutation.isPending,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { useMemo, useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type ScheduleHostInput,
type ScheduleHostSection,
} from "@/schedules/aggregated-schedules";
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
}
export interface UseSchedulesResult {
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
isRefetching: boolean;
}
export function useSchedules(): UseSchedulesResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
});
return {
sections: query.data?.sections ?? [],
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: () => {
void query.refetch();
},
isRefetching: query.isRefetching,
};
}
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
hostFilter?: string | null;
hostFilters?: readonly string[];
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
const hostFilters = options?.hostFilters ?? storeHostFilters;
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
const isActive = options?.enabled !== false;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
if (hostFilters.length === 0) {
return allServerIds;
}
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
const selected = new Set(hostFilters);
const matched = allServerIds.filter((id) => selected.has(id));
// Registry has settled but none of the pinned hosts still exist — fall back to every
// host rather than leaving the sidebar empty.
if (hostRegistryLoaded && matched.length === 0) {
return allServerIds;
}
return matched;
}, [allServerIds, hostFilters, hostRegistryLoaded]);
useEffect(() => {
if (!hostRegistryLoaded) {
return;
}
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
reconcileHostFilters(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
+6
View File
@@ -524,6 +524,12 @@ describe("translation resources", () => {
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
"We'll let you know when the update is ready. Last checked at {{time}}.",
);
expect(en.desktop.updates.status.pendingWithVersion).toBe(
"Update found: {{version}}. Downloading...",
);
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
);
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
"Update ready: {{version}}. Last checked at {{time}}.",
+23
View File
@@ -423,6 +423,25 @@ export const ar: TranslationResources = {
openDevTools: "افتح أدوات تطوير المتصفح",
cancelSelector: "إلغاء محدد العنصر",
selectElement: "حدد العنصر",
grabElement: "نسخ العنصر إلى الحافظة",
grabElementLabel: "العنصر",
grabFailed: "تعذّر نسخ العنصر",
},
annotate: {
title: "إرسال ملاحظات إلى الوكيل",
placeholder: "صف ما الذي ينبغي تغييره…",
submit: "إرفاق",
cancel: "إلغاء",
intents: {
fix: "إصلاح",
change: "تغيير",
question: "سؤال",
approve: "موافقة",
},
},
devices: {
label: "حجم الجهاز",
responsive: "متجاوب",
},
errors: {
failedToLoad: "فشل تحميل الصفحة",
@@ -770,6 +789,7 @@ export const ar: TranslationResources = {
},
sections: {
sessions: "السجل",
schedules: "الجداول",
},
worktreeSetup: {
title: "إعداد البرامج النصية لشجرة العمل",
@@ -939,6 +959,9 @@ export const ar: TranslationResources = {
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
pendingWithVersionAndLastChecked:
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
availableWithVersion: "التحديث جاهز:{{version}}",
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
available: "تحديث التطبيق جاهز للتثبيت.",
+23
View File
@@ -423,6 +423,25 @@ export const en = {
openDevTools: "Open browser dev tools",
cancelSelector: "Cancel element selector",
selectElement: "Select element",
grabElement: "Copy element to clipboard",
grabElementLabel: "element",
grabFailed: "Couldn't copy element",
},
annotate: {
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",
responsive: "Responsive",
},
errors: {
failedToLoad: "Failed to load page",
@@ -777,6 +796,7 @@ export const en = {
},
sections: {
sessions: "History",
schedules: "Schedules",
},
worktreeSetup: {
title: "Set up worktree scripts",
@@ -947,6 +967,9 @@ export const en = {
pending: "We'll let you know when the update is ready.",
pendingWithLastChecked:
"We'll let you know when the update is ready. Last checked at {{time}}.",
pendingWithVersion: "Update found: {{version}}. Downloading...",
pendingWithVersionAndLastChecked:
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
availableWithVersion: "Update ready: {{version}}",
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
available: "An app update is ready to install.",
+23
View File
@@ -427,6 +427,25 @@ export const es: TranslationResources = {
openDevTools: "Abrir herramientas de desarrollo del navegador",
cancelSelector: "Cancelar selector de elementos",
selectElement: "Seleccionar elemento",
grabElement: "Copiar elemento al portapapeles",
grabElementLabel: "elemento",
grabFailed: "No se pudo copiar el elemento",
},
annotate: {
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",
responsive: "Adaptable",
},
errors: {
failedToLoad: "No se pudo cargar la página",
@@ -797,6 +816,7 @@ export const es: TranslationResources = {
},
sections: {
sessions: "Historial",
schedules: "Horarios",
},
worktreeSetup: {
title: "Configurar secuencias de comandos del árbol de trabajo",
@@ -967,6 +987,9 @@ export const es: TranslationResources = {
pending: "Le avisaremos cuando la actualización esté lista.",
pendingWithLastChecked:
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
pendingWithVersionAndLastChecked:
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
availableWithVersion: "Actualización lista:{{version}}",
availableWithVersionAndLastChecked:
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",
+23
View File
@@ -427,6 +427,25 @@ export const fr: TranslationResources = {
openDevTools: "Outils de développement du navigateur ouvert",
cancelSelector: "Annuler le sélecteur d'élément",
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: "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",
responsive: "Adaptatif",
},
errors: {
failedToLoad: "Échec du chargement de la page",
@@ -796,6 +815,7 @@ export const fr: TranslationResources = {
},
sections: {
sessions: "Historique",
schedules: "Planifications",
},
worktreeSetup: {
title: "Configurer les scripts d'arbre de travail",
@@ -966,6 +986,9 @@ export const fr: TranslationResources = {
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
pendingWithLastChecked:
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
pendingWithVersionAndLastChecked:
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
availableWithVersion: "Mise à jour prête:{{version}}",
availableWithVersionAndLastChecked:
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",
+23
View File
@@ -427,6 +427,25 @@ export const ja: TranslationResources = {
openDevTools: "ブラウザ開発ツールを開く",
cancelSelector: "要素セレクターをキャンセル",
selectElement: "要素を選択",
grabElement: "要素をクリップボードにコピー",
grabElementLabel: "要素",
grabFailed: "要素をコピーできませんでした",
},
annotate: {
title: "エージェントにフィードバックを送信",
placeholder: "変更すべき内容を記述…",
submit: "添付",
cancel: "キャンセル",
intents: {
fix: "修正",
change: "変更",
question: "質問",
approve: "承認",
},
},
devices: {
label: "デバイスサイズ",
responsive: "レスポンシブ",
},
errors: {
failedToLoad: "ページの読み込みに失敗しました",
@@ -782,6 +801,7 @@ export const ja: TranslationResources = {
},
sections: {
sessions: "履歴",
schedules: "スケジュール",
},
worktreeSetup: {
title: "ワークツリースクリプトを設定",
@@ -951,6 +971,9 @@ export const ja: TranslationResources = {
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
pending: "更新の準備ができたらお知らせします。",
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
pendingWithVersionAndLastChecked:
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
availableWithVersion: "更新の準備ができました: {{version}}",
availableWithVersionAndLastChecked:
"更新の準備ができました: {{version}}。最終確認: {{time}}。",
+23
View File
@@ -427,6 +427,25 @@ export const ptBR: TranslationResources = {
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
cancelSelector: "Cancelar seletor de elemento",
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: "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",
responsive: "Responsivo",
},
errors: {
failedToLoad: "Falha ao carregar página",
@@ -788,6 +807,7 @@ export const ptBR: TranslationResources = {
},
sections: {
sessions: "Histórico",
schedules: "Agendamentos",
},
worktreeSetup: {
title: "Configurar scripts de worktree",
@@ -958,6 +978,9 @@ export const ptBR: TranslationResources = {
pending: "Avisaremos quando a atualização estiver pronta.",
pendingWithLastChecked:
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
pendingWithVersionAndLastChecked:
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
availableWithVersion: "Atualização pronta: {{version}}",
availableWithVersionAndLastChecked:
"Atualização pronta: {{version}}. Última verificação às {{time}}.",
+23
View File
@@ -427,6 +427,25 @@ export const ru: TranslationResources = {
openDevTools: "Открыть инструменты разработки браузера",
cancelSelector: "Отменить выбор элемента",
selectElement: "Выберите элемент",
grabElement: "Скопировать элемент в буфер обмена",
grabElementLabel: "элемент",
grabFailed: "Не удалось скопировать элемент",
},
annotate: {
title: "Отправить отзыв агенту",
placeholder: "Опишите, что нужно изменить…",
submit: "Прикрепить",
cancel: "Отмена",
intents: {
fix: "Исправить",
change: "Изменить",
question: "Вопрос",
approve: "Одобрить",
},
},
devices: {
label: "Размер устройства",
responsive: "Адаптивный",
},
errors: {
failedToLoad: "Не удалось загрузить страницу",
@@ -789,6 +808,7 @@ export const ru: TranslationResources = {
},
sections: {
sessions: "История",
schedules: "Расписания",
},
worktreeSetup: {
title: "Настройка сценариев рабочего дерева",
@@ -959,6 +979,9 @@ export const ru: TranslationResources = {
pending: "Мы сообщим вам, когда обновление будет готово.",
pendingWithLastChecked:
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
pendingWithVersionAndLastChecked:
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
availableWithVersion: "Обновление готово:{{version}}",
availableWithVersionAndLastChecked:
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",
+23
View File
@@ -423,6 +423,25 @@ export const zhCN: TranslationResources = {
openDevTools: "打开浏览器开发者工具",
cancelSelector: "取消元素选择器",
selectElement: "选择元素",
grabElement: "复制元素到剪贴板",
grabElementLabel: "元素",
grabFailed: "复制元素失败",
},
annotate: {
title: "发送反馈给智能体",
placeholder: "描述需要修改的内容…",
submit: "附加",
cancel: "取消",
intents: {
fix: "修复",
change: "修改",
question: "提问",
approve: "认可",
},
},
devices: {
label: "设备尺寸",
responsive: "自适应",
},
errors: {
failedToLoad: "页面加载失败",
@@ -764,6 +783,7 @@ export const zhCN: TranslationResources = {
},
sections: {
sessions: "历史",
schedules: "计划",
},
worktreeSetup: {
title: "设置 worktree scripts",
@@ -928,6 +948,9 @@ export const zhCN: TranslationResources = {
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
pending: "更新准备好后会通知你。",
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
pendingWithVersion: "发现更新:{{version}}。正在下载...",
pendingWithVersionAndLastChecked:
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
availableWithVersion: "更新已就绪:{{version}}",
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
available: "有 app 更新可安装。",

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