Compare commits

...
Author SHA1 Message Date
Mohamed Boudra b8e24677e1 chore(release): cut 0.8.0 2026-09-10 12:17:08 +02:00
Mohamed Boudra 1f3bb0239b docs(changelog): prepare 0.8.0 stable release 2026-09-10 12:14:08 +02:00
Mohamed Boudra f7e68e32d1 fix(desktop): restore Linux launcher identity (#4649)
* fix(desktop): restore Linux launcher identity

Electron now derives Linux window identity from the scoped package name before app startup. Pin the existing Paseo.desktop identity in packaged metadata and check the built archive in the packaged-app smoke test.

* test(desktop): declare packaged smoke archive dependency

* fix(desktop): update Nix hash for smoke dependency
2026-09-10 11:42:09 +02:00
Mohamed Boudra 94fc4e5d49 docs(release): document temporary next and hotfix branches (#4651) 2026-09-10 11:10:21 +02:00
Mohamed Boudra 7bcf167862 Document scoped credentials during Hub continuation (#4607)
* docs(hub): explain scoped credentials during agent continuation

* docs(hub): align credential lifetime across trigger guides

* docs(hub): centralize continuation credential lifecycle

* docs(hub): keep continuation guidance focused on agent reuse

* docs(hub): state when shared tokens are revoked
2026-09-10 10:08:54 +02:00
Matt Cowgerandpaseo-bot[bot] 8598b6aab9 fix(plugin-provider): recover stale sessions after reload (#4629)
* fix(plugin-provider): recover stale sessions after reload

* fix(plugin-provider): auto-recover stale sessions on next prompt

* fix(plugin-provider): retry stale prompt streams

* fix(plugin-provider): recover replacement prompts

* test(plugin-provider): await runtime retirement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-10 09:20:25 +02:00
Mohamed Boudra ae1cf642bb docs(hub): remove navigation implementation FAQ (#4645)
* docs(hub): explain pending dashboard navigation

* docs(hub): remove navigation implementation FAQ
2026-09-10 09:05:43 +02:00
Mohamed Boudra 2ee0191ea1 docs(hub): explain pending dashboard navigation (#4608) 2026-09-10 09:00:33 +02:00
Mohamed Boudra f3917173a9 fix(server): align Codex Fast with documented model support (#4640)
The prefix allowlist hid Fast for GPT-6 Astra and exposed it for models outside documented Codex Fast support. Use explicit model IDs and avoid claiming a universal usage multiplier.
2026-09-10 08:42:50 +02:00
Mohamed Boudra 92504cd525 docs(hub): document configurable startup timeout (#4601)
* docs(hub): document configurable startup timeout

* docs: keep startup timeout guidance in one place
2026-09-09 23:37:54 +02:00
Tom GringauzandTom Gringauz 726067b437 fix(claude): open a single compaction marker per compaction (#4391)
Suppress repeated Claude compaction heartbeat markers at the provider and re-arm marker creation at compaction and turn boundaries. Verify emitted events through the public session interface, including subsequent compactions after interrupted or abandoned turns.

Closes #4390.

Co-authored-by: Tom Gringauz <17593920+tomgrin10@users.noreply.github.com>
2026-09-09 22:45:30 +02:00
Mohamed Boudra ec4e81ce0f Show Codex questions while the agent keeps working (#4587)
* fix(codex): show asynchronous questions and preserve their lifecycle

* fix(server): reject concurrent replies to the same question

* test(codex): use native temporary paths in question fixtures
2026-09-09 20:32:21 +02:00
Christoph Leiter bc385b5930 fix(app): don't steal focus when workspace creation finishes late (#2986)
Preserve the selected workspace when delayed creation finishes, while still starting the requested agent or terminal. Keep normal navigation when the initiating screen remains active, and preserve newer draft text and context.

Verified with before/after recordings, ten passing real-browser journeys using isolated daemons, focused regression tests, green CI, and independent review.

QA: https://github.com/getpaseo/paseo/tree/qa/workspace-focus-2986

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-09-09 19:45:24 +02:00
Mohamed Boudra ec5ab3fc4d revert(app): undo file Find merge pending maintainer review
Reverts 69510ac72f. The feature must return to an open pull request for maintainer review.
2026-09-09 19:39:14 +02:00
Mohamed Boudra 69510ac72f feat(app): add pane Find to file source (#4586)
Make Find discoverable at the top right of editable and read-only web file panes, using shared controls backed by CodeMirror search. Preserve literal case-insensitive buffer search, keyboard focus and navigation, optional replacement, Undo, and save.
2026-09-09 19:28:36 +02:00
Mohamed Boudra 433e67b18b feat(website): link the community plugin directory
Add a paseo.cafe link to the Plugins card on the landing page and a
section at the top of the related projects doc. The card link is accented
so it reads without hover; the card now takes a list of links instead of
one hardcoded link.
2026-09-09 16:06:08 +02:00
Mohamed Boudra d311797653 Reduce stalls when opening large diffs (#4574)
* perf(app): reduce avoidable work when opening large diffs

Large snapshots were recursively reconstructed for reference stability, while native scroll worklets captured complete file models. Preserve identity at the data producers and prepare text for the visible window without rebuilding unchanged files.

* test(app): await commit diff query completion without polling

* chore(app): remove profiling artifacts from the source tree
2026-09-09 16:05:44 +02:00
Mohamed Boudra 0b3d82a469 feat(plugin): add shared header buttons and composer controls (#4577)
* feat(plugin): add shared header buttons and composer controls

* fix(plugin): isolate button registration imports and settle captures

* test(plugin): express button journeys through domain helpers
2026-09-09 15:39:12 +02:00
Mohamed Boudra 509a3e17bd feat(app): read the changelog in the app instead of the browser (#4576)
What's new opens an adaptive sheet — bottom sheet on compact, centred card on
desktop — that fetches CHANGELOG.md from the repository and renders it with the
app's own chrome. The desktop update callout, the sidebar help menu, and a new
row in Settings → About all open it; the sheet header links out to
paseo.sh/changelog.

The parser commits to two facts only: `##` starts a release and `###` starts a
section. Section titles are read from the document rather than matched against a
list, and everything under a section passes through verbatim to the Markdown
renderer, so a future callout, block quote, code fence or image needs no parser
change. Fences are tracked because a fenced sample is the one thing that can
fool a line scan.

The document is fetched rather than bundled because the app has to describe a
release that shipped after it was built, which is the update callout's whole
purpose. It goes straight to the repository rather than through a host: the
notes describe the app, and routing them through a daemon would make them
depend on which host happens to be connected. Nothing is fetched or parsed
until the sheet opens.

The latest release renders as soon as there is one and the rest of the first
page follows in a transition, so React slices that work around the opening
animation instead of committing hundreds of list items at once.
2026-09-09 15:23:01 +02:00
Mohamed Boudra 19bd6e5909 docs(contributing): route bugs and product discussions to GitHub 2026-09-09 15:14:07 +02:00
Mohamed Boudra 16bfff8e0f docs(product): clarify core philosophy and contribution expectations 2026-09-09 15:04:06 +02:00
Mohamed Boudra fdf3b4b47f fix(app): show loading state for restored terminal titles (#4553)
Restored tabs render before the terminal list arrives. Mark their titles as loading until metadata is available so they do not briefly display the generic terminal name.
2026-09-09 11:30:28 +02:00
Mohamed Boudra 96ea6698d7 fix(app): preserve plugin provider icons in model buttons (#4549)
* fix(app): preserve plugin provider icons in model buttons

Pass the selected host to the composer glyph so it resolves the same plugin SVG as the model picker. Require the nullable glyph host prop to catch omitted identity at compile time.

* test(app): isolate plugin icon regression setup
2026-09-09 11:06:29 +02:00
Edi Hasaj 85cd969321 Allow finished workspaces to be marked unread (#3603)
Restore unread attention on finished workspaces and preserve the reminder when leaving the current workspace. Cover desktop and compact read flows, and retain change-only file paths for deletion observation.

Co-authored-by: Edi Hasaj <34984294+edihasaj@users.noreply.github.com>
2026-09-09 11:01:08 +02:00
Mohamed Boudra c172076bff Initialize Hub with organization triggers (#4517)
* fix(cli): initialize current Hub triggers

Hub init still authored and deployed project bundles after hosted configuration moved to organization triggers. Generate the self-contained trigger contract and keep project bundle deployment behind the existing --project path.

* fix(cli): harden Hub trigger setup

* docs(hub): align public setup guides with organization triggers
2026-09-09 09:15:28 +02:00
Mohamed Boudra 613cbbe9ef Fix Codex continuation after workspace archive (#4512)
* Fix native writer ownership when restoring archived agents

* Keep archive QA mechanics in a scenario helper
2026-09-09 08:51:55 +02:00
Mohamed Boudra da8c1b5c94 Make orchestration setup, profiles, and agent communication discoverable (#4486)
* docs(orchestration): lead with capabilities and practical delegation

* docs(orchestration): correct model requirements and agent list scope
2026-09-08 13:40:21 +02:00
Mohamed Boudra 18aaec2772 fix(client): keep daemon connections lean by default (#4470)
Move supported protocol capabilities into client defaults and make session traffic subscription-driven. Preserve timeline readiness and reconnect recovery across SDK, CLI, and plugin consumers.
2026-09-08 12:59:25 +02:00
Mohamed Boudra 158525624f fix(acp): keep vendor configuration notifications atomic (#4479)
ACP SDK extension dispatch can finish after a later response, letting vendor config notifications escape commit or rollback depending on pipe chunking. Consume synchronous vendor transforms at the stream boundary before forwarding later responses, and reproduce coalesced single and burst notifications through a real subprocess.
2026-09-08 12:59:09 +02:00
Mohamed Boudra 47171b419b fix(release): run release scripts as main on Windows runners (#4474)
The ESM main-module guard compared import.meta.url with "file://" + process.argv[1],
which never matches on Windows (file://D:\a\... vs file:///D:/a/...). On the Windows
desktop release job, scripts/github-release.mjs therefore printed nothing and
gh release upload received an empty release lookup ("release not found"), leaving
v0.8.0-beta.1 without a Windows build. Compare resolved paths instead, shared by
every release script that runs as a CLI.
2026-09-08 11:07:13 +02:00
paseo-ai[bot] f809651876 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-08 08:29:09 +00:00
Mohamed Boudra 4eab53e24e chore(release): cut 0.8.0-beta.1 2026-09-08 10:19:53 +02:00
Mohamed Boudra 341a86aa52 docs(changelog): add 0.8.0-beta.1 entry 2026-09-08 09:51:21 +02:00
Mohamed Boudra 1622da40d9 Keep the mobile terminal keyboard open while typing (#4469)
* fix(app): keep terminal keyboard focus stable

Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing.

* test(app): verify terminal keyboard continuity on Android

Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior.
2026-09-08 09:48:48 +02:00
Mohamed Boudra 229f3cd5a8 Accept prerelease Paseo versions in plugin requirements and prepare plugin docs for the 0.8 beta (#4452)
Accept plugin requirements against the runtime version or its stable core while preserving the legacy version boundary. Validate all example manifests, correct the ACP example metadata, and prepare plugin author documentation for the beta.
2026-09-08 08:30:52 +02:00
1f5b6143d6 Enforce shared, client, and server plugin SDK boundaries (#4347)
* fix(plugins): keep React out of the server plugin host

* fix(plugins): align SDK guidance and tests with runtime boundaries

* fix(plugin): loosen the react peer to ~19.1.0

The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.

Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.

Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.

The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.

Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.

* Enforce example import ownership including client types

* Update Nix dependency hash for plugin peer metadata

* Group plugin APIs under client and server entry trees

* Resolve plugin type dependencies through declarations

* Validate declarations for implicit plugin type imports

* Preserve optional imports and validate declaration references

* Refresh Nix hash for plugin declaration resolver

* Canonicalize plugin paths consistently on Windows

* Normalize plugin ownership paths across resolver boundaries

---------

Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-07 23:42:28 +02:00
Mohamed Boudra d779514f10 Keep daemon reconnect running while the app is backgrounded (#4444)
* fix(app): keep reconnect enabled while backgrounded

Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution.

* test(app): clarify the background reconnect journey

* test(app): name reconnect screenshot helper inputs

* test(app): wait for stable content before delayed-history scroll

* chore(app): keep reconnect QA evidence in the PR body
2026-09-07 23:34:37 +02:00
Mohamed Boudra 0859a67ab1 fix(app): show workspace plugin panels in Explorer menu (#4446)
* fix(app): show workspace plugin panels in Explorer menu

Let the host-filtered launch catalog identify toggleable views and their targets. Explorer uses the same identity comparison for every view while plugin panels remain workspace-scoped.

* test(app): express Explorer panel regression as user steps
2026-09-07 21:46:59 +02:00
LiuJingandMohamed Boudra 56fd50988c fix(plugin): loosen the react peer to ~19.1.0 (#4100)
* fix(plugin): loosen the react peer to ~19.1.0

The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.

Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.

Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.

The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.

Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.

* Update Nix dependency hash for plugin peer metadata

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-07 21:40:30 +02:00
Mohamed Boudra 469d51289b Keep 0.7.2 apps working with 0.8 daemons (#4443)
* fix(protocol): keep 0.7.2 clients working with 0.8 hosts

Gate plugin timeline rows and blocked workspace setup statuses per client capability so app-store version lag does not make existing agents unloadable. Preserve canonical pagination and document the intended Hub provider-mode authority.

* test(protocol): clarify compatibility test preconditions
2026-09-07 21:26:30 +02:00
Karan SharmaandMohamed Boudra a7a708bec9 fix(server): support OpenCode 2 in terminal activity plugin (#4300)
* fix(server): support OpenCode 2 in terminal activity plugin

OpenCode 2 rejects V1 hook-object plugins, so the installed
paseo-terminal-activity.js failed to load on every OpenCode 2
session. Ship one definition object that serves both generations:
OpenCode 2 loads id + setup() and ignores the 1.x server entrypoint,
OpenCode 1 loads the same object through server().

* fix(server): preserve terminal activity across plugin generations

* test(server): drain persistence before reload test cleanup

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-07 21:08:50 +02:00
Ivan Brykov c43df5d4c3 feat(client): expose provider usage (#4062)
* feat(client): expose provider usage

* fix(client): gate provider usage by capability
2026-09-07 19:52:34 +02:00
Mohamed Boudra c46aee4e6a Revert #4421 and narrow the cache restore fix (#4436)
* Revert "Restore cached conversations before reconnecting (#4421)"

This reverts commit 1c51b52d07.

* fix(app): restore provider loading on native

* fix(app): preserve native navigators across appearance changes

Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes.

* fix(app): retain cached baselines during live updates

Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard.
2026-09-07 19:18:54 +02:00
Mohamed Boudra 9c8d775895 Let plugins customize agent creation and observe lifecycle events (#4435)
* feat(plugins): expose agent and workspace lifecycle hooks

* test(plugins): verify lifecycle examples with version requirements

* fix(plugins): publish archive hooks for closed agents

* test(plugins): remove diagnostic catch from lifecycle test

* refactor(plugins): define hook handlers in lifecycle module
2026-09-07 19:17:42 +02:00
Mohamed Boudra 8c89e72a06 fix(app): restore coordinated bottom sheet gestures (#4431)
Supplying a list controls scroll-container ownership, not whether content can move the sheet. Preserve the shared expand-scroll-dismiss interaction for model selectors and plugin lists, with native gesture regressions and updated plugin guidance.
2026-09-07 17:39:17 +02:00
Mohamed Boudra a5d2b7d863 Declare and enforce plugin Paseo version requirements (#4430)
* feat(plugins): enforce declared Paseo version requirements

Treat manifests without a Paseo requirement as pre-0.8 plugins so the first breaking plugin release offers an explicit migration path. Check the daemon and app independently before plugin execution, and preserve installed Git revisions when updates are incompatible.

* fix(nix): refresh dependency hash for plugin requirements

* refactor(plugins): validate test manifest inputs and name compatibility input
2026-09-07 17:20:19 +02:00
Mohamed Boudra 1c51b52d07 Restore cached conversations before reconnecting (#4421)
* fix(app): restore cached conversations before reconnecting

Give each host one timeline write authority so cache restores, live events and fetched pages publish consistent rows and coverage. Persist a moving 40-entry restart page independently of loaded scrollback, and keep native navigation mounted while startup settings hydrate.

* fix(app): recover cache writes after initialization failures

Keep accepted writes pending and retry initialization without another UI update. Publish the storage index only after loading succeeds, while queued host identity changes still settle in order.

* test(app): wait for sidebar and workspace transitions

Viewport intersection and leaving the creation route can precede completed UI transitions. Wait for the layering hit test and selected agent tab before asserting or navigating again.

* fix(app): restore provider loading on native clients

The native AbortSignal lacks throwIfAborted, so successful provider responses failed before reaching every model selector. Preserve both cancellation boundaries using the supported aborted property and the existing query cancellation error.
2026-09-07 15:52:21 +02:00
Michael JaklandMohamed Boudra c424f82922 fix(pi): settle autonomous Pi turns triggered by extensions (#3849)
* Settle autonomous Pi turns triggered by extensions

Treat Pi's agent lifecycle as authoritative for extension-triggered turns that do not have a Paseo provider turn ID. Keep locally handled extension commands immediate, but defer autonomous completion until agent_settled.

* Fail autonomous Pi turns on process exit

* Fix cancellation of autonomous Pi turns

* Synchronize Pi cancellation regressions with queue clearing

* Release Pi interruption state at its terminal boundary

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-07 08:57:01 +02:00
Omer Cohenandpaseo-bot[bot] b403dea32b feat(client): expose paseo.projects.subscribe() through the public SDK (#3983)
* feat(client): expose project update subscriptions

* docs(sdk): clarify project subscription initialization

* docs(sdk): document race-free project initialization

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-06 21:40:23 +02:00
Mohamed Boudra dd7671be20 Remember model selections across workspace creation (#4401)
* fix(providers): remember model selections across workspace creation

Keep saved model intent independent of catalogue readiness and use the caller-owned host and directory from the first render. Share discovery through provider-owned keys while preserving execution context.

Give each target one committed snapshot, publish actual transitions atomically, and share catalogue content across client directory associations. Regression coverage exercises successful repeated creation and provider lifecycle boundaries.

* fix(providers): preserve shared cache bodies during eviction

Admit directory references with their bodies, retain reference recency during eviction and recovery, and remove the unused persisted index. Refresh known workspace catalogues concurrently.

Make repeated-creation picker states explicit and seed mode tests from the installed provider catalogue, preserving assertions across Codex versions.

* fix(providers): bound discovery independently per provider

Settings refresh repairs all remembered targets, which otherwise starts an unbounded number of discoveries. Admit work after catalogue-key deduplication and keep each provider’s budget independent so a stalled provider cannot block another. Preserve active accounting across configuration changes and settle obsolete queued requests during replacement and shutdown.
2026-09-06 20:29:41 +02:00
Mohamed Boudra 38c22139bb Let plugins control modal layout, scrolling and clipboard actions (#4392)
* feat(plugins): support modal body layout, scrolling and clipboard actions

* fix(app): keep native scroll dependencies out of web imports

* fix(nix): sync dependency hash for plugin native types

* test(plugins): express modal QA as user journeys
2026-09-06 13:48:29 +02:00
Mohamed Boudra c99b8ad5f4 fix(app): simplify Changes empty-state copy (#4393) 2026-09-06 13:36:18 +02:00
Mohamed Boudra e4cd2d1ad0 fix(desktop): preserve the packaged macOS Dock icon (#4389)
The startup override replaced the bundle icon with a flat PNG even in packaged builds. Limit the override to development so macOS retains the bundle icon appearance.
2026-09-06 12:34:17 +02:00
Mohamed Boudra ae80ec4252 Let plugins contribute settings screens (#4357)
* feat(plugins): contribute settings screens and shared controls

* fix(plugins): localize settings and verify shared UI consumers

* test(composer): unmount draft test roots after each case

* test(app): keep launch terminals alive until cleanup

The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it.

* test(server): await the initial snapshot before a buffered ref event

Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh.
2026-09-06 10:22:26 +02:00
Mohamed Boudra ab7d66dfed Let Hub continue agents through the standard daemon API (#4354)
* feat(hub): continue agents through standard daemon operations

Authorize ordinary agent and workspace recovery operations with hub.execute. Keep conversation routing in Hub and persist generic creation and message receipts so retries cannot silently duplicate work.

* fix(hub): expose custom providers to continuing clients

Allow Hub to advertise provider support independently of the app version. Document the companion continuation policy and migration. Preserve newly observed files in the native watcher inventory so coalesced deletions remain visible to reconciliation, addressing the Windows CI failure.

* fix(hub): allow retries after confirmed local failures

Prepare message loading before recording dispatch intent. Remove failed creation receipts only when creation cleanup completed and no live or stored agent exists. Keep ambiguous provider deliveries fail-closed. Exercise native file observation with real filesystem dependencies.
2026-09-06 10:19:40 +02:00
Mohamed Boudra efd6023b90 Restore live sessions immediately after app resume (#4160)
* perf(app): restore live sessions immediately after resume

Foregrounding now reconnects every host without waiting for scheduled backoff, while cached turns, timelines, agents, and workspaces remain internally consistent until authoritative catch-up arrives. Replica persistence is keyed and incremental so ordinary updates avoid whole-directory serialization.

* fix(app): preserve turn ownership through session updates

Stream turn closure now wakes queued-message delivery, while same-turn snapshots retain local cancellation state. Offline liveness remains visible until an authoritative close arrives.

* fix(app): preserve pending chat cache writes and trim resume refactor

Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration.

* fix(app): initialize reconnect policy from current app state

Initialize visibility before host bootstrap so a background mount cannot leave reconnect enabled. Seed sidebar fixtures through the workspace owner so cache hydration preserves their rows.

* test(app): verify submission settlement across reconnect

Keep submission acknowledgement separate from turn liveness: disconnect preserves the running turn until authoritative catch-up. Wait for confirmed running chrome before exercising queued cancellation so the alternate-send shortcut cannot race turn startup.

* docs: remove cache implementation recap
2026-09-06 10:18:41 +02:00
Mohamed Boudra c8a417c4fb Address terminals by workspace ID in the SDK and CLI (#4358)
* feat(terminals): expose workspace ownership through the SDK and CLI

Directory-based terminal creation selected the oldest workspace sharing that path. Expose explicit workspace ownership in the shared public API and CLI, retain ownership and process directories in listings, and acknowledge terminal deletion after teardown.

* fix(file-observer): preserve nested shallow reconciliation scopes

A parent directory scan does not visit its descendants. Collapsing change scopes discarded nested scans and left coalesced native deletions unseen until a full audit.

* test(terminals): extract output waits from end-to-end scenarios
2026-09-06 10:17:58 +02:00
Mohamed Boudra 78b285059f Fix agent reloads blocked by an active session writer (#4353)
* fix(server): keep agent reloads from overlapping session writers

Idle runtimes can still own their persisted session writer. Release the original runtime before resuming and retain failed cleanup for retry; a timeout must not authorize another writer.

* fix(server): honor closes queued during agent reload

Serialize public close with reload so a completed close cannot be undone by replacement registration. Keep archive cleanup inside its existing lifecycle operation.
2026-09-05 14:55:19 +02:00
Omer Cohenandpaseo-bot[bot] 6f76a8b139 feat(client): expose agent permission responses through the public SDK (#3985)
* feat(client): expose agent permission responses

* test(client): verify permission response wire message

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-05 14:44:24 +02:00
Mohamed Boudra 02e611f3dd Hide empty ACP sessions from session import (#4335)
* fix(server): filter empty ACP import sessions

ACP metadata probes can leave persisted sessions that later appear in import results. Replay available session history to exclude truly empty entries while retaining sessions whose history is unavailable, and close probe sessions before process teardown.

* fix(server): bound ACP session history loading

Keep import enrichment inside the provider listing deadline when session histories are inaccessible. Catalog abort cleanup now also waits briefly for an in-flight session creation so the resulting probe session can be closed.

* test(server): avoid wall-clock ACP assertion

Verify the history budget through the number of attempted protocol loads instead of elapsed process time, so scheduler delays cannot make the regression flaky.

* fix(server): allow slower ACP history loads

Give native session history more time to replay while retaining a shared enrichment budget below the provider listing deadline.

* fix(server): give ACP history replay enough time

Use the provider RPC timeout scale for native history loading, with a larger shared budget and provider-list deadline. Keep timeout coverage fast and deterministic with a controlled clock.

* fix(server): isolate generous ACP import deadlines

Keep the long listing deadline for the picker’s single-provider requests while preserving the short aggregate deadline for callers that fan out across providers. Restore deterministic coverage of unresolved history loads through fake timers and an injected clock boundary.

* fix(server): preserve ACP aggregate listings
2026-09-05 11:29:56 +02:00
Mohamed Boudra f4b209be4d Preserve nested subagent ownership (#4321)
* fix(subagents): preserve recursive provider ownership

Provider events retained enough lineage to identify nested work, but the canonical descriptor discarded the direct owner. Preserve that relationship at ingestion so child timelines and hierarchy remain attached to the process that emitted them, including after history replay.

* test(e2e): clarify nested ownership journey

* test(e2e): separate nested ownership actions

* fix(subagents): retain nested ownership across replay

Wait and interaction events can reference descendants without spawning them. Preserve the original spawning owner and route persisted background notifications through that same relationship after history reload.

* test(subagents): hide provider turn plumbing

* fix(subagents): scope nested tracks to their host

Plugin-provided icons are host-specific. Carry the panel host through nested child tracks so the rebased ownership UI resolves the same provider presentation as its parent.

* fix(workspaces): preserve launched terminal focus
2026-09-04 23:49:26 +02:00
Mohamed Boudra c610676203 fix(providers): preserve state across configuration reloads (#4332)
A provider configuration edit invalidated every cached catalog and could turn healthy providers into false availability errors under the resulting probe burst. Preserve unchanged registry entries, clients, loads, and snapshots, and keep the submitted draft snapshot stable while creation is pending.
2026-09-04 23:27:44 +02:00
Mohamed Boudra 9e90b86afa Restore reliable macOS desktop updates (#4322)
* fix(desktop): restore macOS app updates

Electron 41's Squirrel helper can leave the update handoff dormant on current macOS, so Paseo quits cleanly but relaunches the old bundle. Electron 44 wakes ShipIt through its XPC service.

Include updater lifecycle events and ShipIt state in app diagnostics so future handoff failures identify the current version, target version, cache paths, and installer output.

* fix(desktop): harden updater diagnostics

Keep each ShipIt file's evidence available when another read fails, carry the service-selected version into updater lifecycle logs, and make clean workspace installs resolve the Electron runtime used by desktop tests.

* refactor(desktop): name updater install options

* refactor(desktop): name updater log payloads

* refactor(desktop): name clipboard payload

* fix(desktop): surface updater lookup failures

* test(desktop): await guest focus transition

* fix(desktop): guard macOS update compatibility

* test(release): validate desktop manifests before publish

* test(desktop): await populated daemon pid file
2026-09-04 22:17:39 +02:00
Mohamed Boudra 35d2457d83 Let plugins provide coding agents (#4314)
Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation.

Closes #3547
Closes #3556
2026-09-04 19:56:57 +02:00
Mohamed Boudra 140b0bb716 fix(app): keep long agent timelines smooth across autonomous turns
Scope provider tool-call identity to its autonomous turn so repeated IDs remain distinct, lifecycle updates still merge correctly, and native timeline lists no longer receive duplicate keys.
2026-09-04 14:23:43 +02:00
Ivan MalisonandClaude Fable 5.1 92442e7435 fix(server): reject pending Codex app-server requests on dispose (#4283)
The provider refresh deadline aborts a Codex catalog probe by disposing
the app-server client, but dispose killed the child without settling
in-flight requests. With the request fallback timeout at 14 days, an
unanswered `initialize` left `fetchCatalog` pending forever, so the
workspace snapshot entry stayed "loading" and the model picker showed
"Loading" for every Codex profile with no timeout ever surfacing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 00:50:26 +02:00
Trim21andMohamed Boudra 75eb9d44a3 feat(agents): surface provider notifications in the timeline (#3411)
* feat(pi): surface extension notify in the agent timeline

Pi extension ctx.ui.notify() messages were dropped or buffered away
and never appeared anywhere in Paseo. Emit them as notification
timeline items so they show in the agent chat feed, with
info/warning/error levels mapped to activity log styling.

* fix id

* feat(agents): unify timeline notifications

Represent Pi and OpenCode notices as one first-class timeline item instead of provider-specific synthetic tool calls. Gate the new wire variant so older strict clients keep working during version drift.

* test(omp): update notice expectations

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-03 21:38:15 +02:00
Mohamed Boudra bb676ef086 Smooth mobile keyboard transitions (#4275)
* perf(app): smooth mobile keyboard transitions

Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start.

* fix(app): isolate native keyboard implementation
2026-09-03 20:27:19 +02:00
Mohamed Boudra 53c9607479 Control Paseo tools per provider (#4277)
Add backend-only exact-provider controls for the Paseo tool catalog across native and MCP-backed agents, including persisted configuration, session snapshots, public documentation, and regression coverage.\n\nCo-authored-by: Matt Cowger <matt@cowger.us>
2026-09-03 20:10:47 +02:00
Mohamed Boudra 25defbafb4 Make plugin upgrade failures actionable (#4265)
* fix(plugins): clarify upgrade diagnostics

Keep plugin inspection network-free and consolidate runtime, source, and installed revision details under plugin ls. Legacy entry failures now explain the required v0.8 migration in user-facing language.

* test(cli): align hidden plugin status command
2026-09-03 13:22:21 +02:00
Mohamed Boudra eb753813a1 feat(hub): expose provider catalogs to executions (#4242)
Hub execution credentials need provider snapshot access so remote workflow editors can present the daemon's native models, modes, and thinking options without gaining broader daemon read authority.
2026-09-03 00:08:30 +02:00
Mohamed Boudra 20ecdee72d docs(plugins): version breaking API guides (#4245)
Keep stable v0.7 guidance available while the client-entry layout is previewed for v0.8. Existing reference and migration URLs redirect to the release that owns their content.
2026-09-02 23:45:23 +02:00
Mohamed Boudra ce07732d62 fix(diff): keep file headers synchronized while scrolling (#4240)
The fixed header overlay repainted after compositor-driven scroll, so ordinary headers visibly lagged behind the diff body. Paint in-flow headers with the scrolling canvas and reserve a bounded sticky canvas pool for push-off behavior.
2026-09-02 22:54:38 +02:00
Mohamed Boudra a835136854 Let CLI host selection work globally (#4238)
* feat(cli): accept host as a global option

Keep the command-local position for compatibility while letting users place one daemon target before the command and reuse it while changing verbs. Local daemon lifecycle commands continue to operate locally.

* fix(cli): preserve project host resolution boundary

Project creation already handled PASEO_HOST directly. Avoid routing that established behavior through a newly exported client helper so its isolated command tests keep their existing dependency boundary.

* test(cli): allow scaffold typecheck under CI load

* test(cli): provide remote cwd in schedule e2e

* docs(cli): teach only global host syntax
2026-09-02 22:53:55 +02:00
Mohamed Boudra 7178583612 Stop Settings navigation from retaining closed views (#4228)
* fix(desktop): stop retaining closed Settings views

Settings rotations left detached detail panes reachable from the web style registry's strong element sets. Backport the upstream weak cleanup design and cover the real Electron bundle with a forced-GC regression.\n\nKeep the established sibling-route topology; compact Back relies on route-name identity.

* test(app): synchronize Mermaid completion layout assertion

The streaming acceptance test can observe the intentional completion remount between visibility and layout sampling. Consult the existing completion promise around that measurement so the transition is not reported as diagram loss.

* test(app): isolate worktree restart from provider startup
2026-09-02 21:27:30 +02:00
758 changed files with 47170 additions and 8625 deletions
+1
View File
@@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this,
### Checklist
- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable)
- [ ] One focused change
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
+15
View File
@@ -50,11 +50,26 @@ app:
- "packages/expo-two-way-audio/**"
sdk:
- "packages/plugin/**"
- "plugin-examples/**"
- "public-docs/plugins/v0.8/**"
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
browser:
- "packages/server/src/server/agent/provider-snapshot-manager.ts"
- "packages/server/src/server/session/provider/provider-catalog-session.ts"
- "packages/client/src/compat/normalize-provider-models.ts"
- "packages/protocol/src/client-capabilities.ts"
- "packages/server/src/server/agent/provider-registry.ts"
- "packages/server/src/server/agent/agent-sdk-types.ts"
- "packages/server/src/server/agent/providers/codex-app-server-agent.ts"
- "packages/server/src/server/agent/providers/claude/agent.ts"
- "packages/server/src/server/agent/plugin-provider.ts"
- "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts"
- "packages/server/src/executable-resolution/**"
- "packages/plugin/src/server/provider.ts"
- "packages/app/src/!(desktop)/**"
- "packages/app/e2e/browser/**"
- "packages/app/e2e/support/**"
+9 -23
View File
@@ -59,8 +59,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -80,8 +78,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -107,8 +103,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -137,8 +131,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps: &server_test_steps
- uses: actions/checkout@v4
with:
@@ -180,8 +172,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: windows-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps: *server_test_steps
desktop-tests-ubuntu:
@@ -268,8 +258,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -296,8 +284,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -312,6 +298,15 @@ jobs:
- name: Build client dependencies
run: npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Typecheck plugin examples
run: npm run typecheck --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
@@ -330,7 +325,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "1/4"
PLAYWRIGHT_ARTIFACT: "1"
steps: &playwright_test_steps
@@ -378,7 +372,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "2/4"
PLAYWRIGHT_ARTIFACT: "2"
steps: *playwright_test_steps
@@ -389,7 +382,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "3/4"
PLAYWRIGHT_ARTIFACT: "3"
steps: *playwright_test_steps
@@ -400,7 +392,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PLAYWRIGHT_SHARD: "4/4"
PLAYWRIGHT_ARTIFACT: "4"
steps: *playwright_test_steps
@@ -410,8 +401,6 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -435,7 +424,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
@@ -467,7 +455,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
@@ -481,7 +468,6 @@ jobs:
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
+4 -22
View File
@@ -547,28 +547,10 @@ jobs:
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
# shellcheck disable=SC2016
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expectedHours) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
}
if (m.releaseDate !== expectedDate) {
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' "${files[@]}"
node ../scripts/validate-desktop-manifests.mjs \
--release-date "$timestamp" \
--rollout-hours "$ROLLOUT_HOURS" \
"${files[@]}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
failed_builds=()
+94
View File
@@ -1,5 +1,99 @@
# Changelog
## 0.8.0 - 2026-09-10
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
**Before upgrading:** desktop now requires macOS 13 or newer. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
### Added
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
### Plugins
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
### Changed
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
### Improved
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
### Fixed
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
## 0.7.2 - 2026-09-02
### Added
+7
View File
@@ -106,6 +106,13 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
+61 -97
View File
@@ -1,140 +1,104 @@
# Contributing to Paseo
Thank you for taking the time to contribute to Paseo.
This guide is here to save us both time and help you find a useful way to contribute.
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
## Philosophy
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
**Paseo has a lean, opinionated core built to be extended.**
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
## Report bugs in GitHub issues or Discord
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
## Build a plugin
> [!IMPORTANT]
> Feature requests opened as issues will get closed automatically
Most specialized workflows and integrations are better served by plugins.
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
You may submit a PR to fix a bug, please read the PR guidelines below.
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
## Product discussions go in GitHub Discussions or Discord
## Report bugs
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
Include:
- What you did, what you expected, and what happened.
- Reproduction steps, Paseo version, and platform.
- Relevant logs, screenshots, or a recording.
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
## Discuss workflows before proposing features
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
Explain:
- What are you trying to do?
- How do you do it today?
- Where does Paseo get in the way?
- What would the flow look like if it worked for you?
- What would a better workflow look like?
"Can you build X" is not useful because it doesn't tell me about your problem.
This helps other people describe their needs and gives me useful context when deciding what to build.
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
## If you still want to submit a pull request
## Pull requests
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
Here is the criteria I use to decide:
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
✅ Likely to be accepted
If you choose to submit a PR, these are the basics to cover.
- Fits the product vision
- One focused change
- Links the bug it fixes, or the discussion behind it if there is one
- Addresses bot reviews
- Explains the problem you're solving
- QA evidence
- Automated tests
- Screenshots or video for UI changes, on every affected platform
- Says which platforms you tested and which you didn't
- Maintainer edits enabled
### What I look for in a core feature
⛔️ Will be rejected
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
- Bundles unrelated changes
- Fails checks
- Ignores bot reviews
- Takes a feature or the design in a direction I don't want
- No QA evidence
- No tests
- Clearly fully AI-generated PR
### Keep the scope small
### What to expect
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
- PRs that were explicitly approved in a discussion are preferred.
- Unsolicited PR can be closed without a detailed review.
- Your PR can be narrowed, refactored, or redesigned.
- Your PR might be accepted but not merged immediately.
- You will be attributed for your work in the changelog, even if I redesign your PR
### QA evidence
## QA evidence
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
Explain the problem and link the relevant bug report or discussion. Include:
Pull requests without evidence will be closed.
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
- The commands you ran and their output.
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
- The platforms you tested and any affected platforms you could not test.
What's accepted:
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
- The shell commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the whole interaction
- Logs, requests, responses
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
**Satisfying all of the above does not mean your pull request will be merged.**
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
### What happens to your contribution
## I want to become a maintainer
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
## FAQ
## Help the community
### I'm blocked, where do I go?
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
### Do you prefer Github or Discord?
I am more active on Discord for general questions.
I prefer Github for bugs because the template helps collect information more efficiently.
### Why was my feature request closed?
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
### Why didn't my PR get a detailed review?
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
### How long until someone looks at my pull request?
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
### Why do you want tests and a video for a small change?
Because it's faster and safer than me checking out and running every PR myself to test it manually.
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
### I only have one platform, can I still contribute?
Yes. Test what you have, and say what you didn't test.
### Can I use an agent to write my contribution?
Yes, as long as you understand what you're submitting and you tested it yourself.
### Is there a roadmap I can read?
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
+9
View File
@@ -47,6 +47,15 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
+12 -3
View File
@@ -21,6 +21,10 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
Reload releases the old runtime before resuming its durable session: an idle provider process can
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
@@ -144,6 +148,10 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
A finished workspace can be marked unread after it has been reviewed. The daemon restores
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
@@ -156,9 +164,9 @@ The rows combine two kinds of children:
parentAgentId === thisAgent.id AND !archivedAt
```
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
@@ -174,7 +182,8 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
+3 -2
View File
@@ -94,9 +94,10 @@ facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config.
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
[the client contract](protocol-compatibility.md#client-capability-ownership).
### `packages/app` — Mobile + web client (Expo)
+41 -2
View File
@@ -275,6 +275,45 @@ rather than storing something it cannot describe. That is why the client gates t
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
older daemon.
### Agent provider Paseo tools
`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are
`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal
configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`.
Each entry may include a Paseo-tool policy:
```json
{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"paseoTools": {
"enabled": true,
"disabledTools": ["browser_evaluate"]
}
}
}
}
}
```
Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are
allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool
IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool.
Browser tools also require `daemon.browserTools.enabled` and a connected browser host.
This policy controls the catalog presented to an agent. It is not an authorization boundary for
agents that can access the host through a shell.
`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives
Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a
session is created, resumed, imported, or reloaded, so configuration changes affect the next
session rather than an already-running one.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
### Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
@@ -535,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
and have at most one row per host.
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
uses the singleton id.
The store is a typed persistence boundary. It returns values to directory and timeline owners and
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
+1 -1
View File
@@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
+6 -3
View File
@@ -556,13 +556,16 @@ npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host` to point the CLI at a different daemon:
Use the global `--host` option to point the CLI at a different daemon:
```bash
npm run cli -- ls -a --host localhost:7777
npm run cli -- ls -a --host ssh://user@host
npm run cli -- --host localhost:7777 ls -a
npm run cli -- --host ssh://user@host ls -a
```
Set `PASEO_HOST` to use the same target across invocations. An explicit
`--host` overrides the environment variable.
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
Desktop integrations can focus an existing agent without creating one or
+4 -3
View File
@@ -39,9 +39,10 @@ that pane from the workspace split tree and docks it separately. Persisted ident
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles the singleton Files and Changes views. Individual tab menus close instances or move
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
an Explorer tab does not change workspace focus.
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
+25 -1
View File
@@ -64,7 +64,10 @@ 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.
navigation. Both paths wait for the root navigation container to be ready. The
workspace navigation owner keeps the latest pending intent across ref
re-registration and applies it on the container's `ready` event; routes must not
add their own readiness retries.
Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen. Follow the
@@ -77,6 +80,19 @@ 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.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
distinct route names. `router.dismissTo()` ultimately matches stack entries by
route name. A single catch-all Settings route would make project detail and the
projects index the same route; Back would update params in place and leave a
phantom detail entry underneath. The host routes also stay outside the
store-ready protected group so a cold host deep link can survive daemon startup.
Do not collapse this topology with a catch-all, `getId`, or
`dangerouslySingular` workaround.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
@@ -131,6 +147,14 @@ Do not read the active theme with `useUnistyles()` in a layout to build
a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
Navigators keep their identity across appearance changes. `ThemedStack` remounts
each screen's content below the native stack when appearance tokens change; a
keyed wrapper above a stack remounts the stack itself, and Android crashes when
that happens while a FragmentManager transaction is running, which settings
hydration at startup makes likely. A screen that mounts a nested navigator (the
root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested
stack owns its own screens. See [unistyles.md](unistyles.md).
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native
+31 -15
View File
@@ -134,19 +134,39 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Keyboard layout and portal anchors
Move the chat surface with worklet-driven bottom padding from
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
and native hit testing in the same layout. Do not translate the stream and
composer independently.
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
signal because the stock iOS Reanimated value changes at move start, while
writing a replacement Reanimated value every frame interrupts UIKit's hide
animation. Keep this platform choice inside `KeyboardTranslateView`.
Do not use the controller's raw keyboard progress for the padding. It can retain
a nonzero value after the keyboard closes. The shared provider normalizes that
state and reconciles native animation-end events.
`KeyboardDock` always keeps the chat surface at full height and wraps it in
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
band or a paused transition when the inverted list updates its viewport.
`measureInWindow` already includes the dock's current padding layout. When a
portal opens, snapshot the current shift and apply only the subsequent shift
delta to its animated `bottom`. Adding the full shift moves the portal twice and
can place it over the composer controls.
Do not animate a layout prop through the keyboard transition. On Android
Fabric, each animated layout update clones the shadow tree and runs Yoga over
the dock subtree. This measured about 10 ms per frame on the emulator and the
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
Move the stream and composer together through `KeyboardDock`. Do not translate
them independently.
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
portal geometry, plus the `isMoving` worklet value. It reconciles native
animation-end events because the controller can retain a nonzero value after
the keyboard closes.
Measure portal anchors while the dock is at rest. If a popover opens during
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
dock's current layout and transform. Snapshot the shift at measurement time and
apply only the subsequent shift delta to the animated `bottom`. Adding the full
shift moves the portal twice and can place it over the composer controls.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
@@ -155,10 +175,6 @@ leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Host-relative positioning before platform offsets
The generic anchored-overlay rule is:
+8
View File
@@ -78,6 +78,14 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a
timezone) once — never in a preview line AND a helper line.
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
## Settings rows
Use the named components from `@/components/settings` for settings cards and controls.
`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens
can import `SettingsGroup` and `SettingsSection` directly from their modules in
`@/components/settings/headings/`.
The plugin UI entry exposes the same components; keep them independent of storage and routing.
## Data gating
Aggregate hooks return a discriminated load state:
+36 -57
View File
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
## Connection and authority
@@ -25,69 +25,48 @@ during interactive login or later with `paseo hub permissions grant hub.execute`
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.
## Session grants and execution ownership
## Session grants and agent operations
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
response returns the existing agent and its current state; there is no separate reconciliation RPC.
Transient stream frames are not durably replayed.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
replays the original prompt. A duplicate create returns the existing agent without starting another
turn.
`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit
`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
record so provider sessions can recover after a restart; neither ordinary client snapshots and
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
supported provider keys.
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
hashes, never prompts, environment values, or credentials.
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
Every reference must name an MCP server injected by the same create request. The daemon translates
only those identities into the selected provider's native approval configuration. The protocol
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
Hub creation instead of broadening access or waiting for an invisible prompt.
When a create request includes a tool policy, a successful response includes
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then
`workspace.recovery.restore.request` and await success. The native message handler unarchives the
agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's
native steering behavior, including Paseo's existing behavior when that provider cannot steer.
Execution completion and arrival-specific output authority remain Hub responsibilities.
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
and the provider's local or managed policy remain the authorization controls.
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
the Hub execution is terminal.
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
actions are idempotent and continue to resolve from stored ownership after daemon restart.
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
required workspaceId and sends it through the shared workspace archive service. The service archives
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
sharing a backing directory remain active.
The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership
and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs.
## Disconnect and revocation
+3 -1
View File
@@ -33,7 +33,9 @@ The UI worklet owns transient motion:
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
React owns pointer events and accessibility.
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
## Why one position
+12
View File
@@ -35,6 +35,18 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs
```
Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's
focused input identity and IME hide/show events. It saves screenshots and logs under
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
to test an installed production build. It never submits a chat message.
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | The Hub-owned execution lifecycle |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+131 -60
View File
@@ -1,7 +1,7 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
@@ -41,7 +41,7 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned
directories, and local typechecking support. At least one entry is required.
```text
@@ -62,10 +62,15 @@ runtime modules, so consumers do not install these packages when adding the plug
```json
{
"id": "my-plugin"
"id": "my-plugin",
"requirements": { "paseo": ">=0.8.0" }
}
```
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
manifests and prerelease matching.
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
runtime does not compare the two IDs. The same directory can be installed under several config
@@ -91,7 +96,7 @@ paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin status
paseo plugin ls
paseo plugin update review
paseo plugin update --all
```
@@ -99,8 +104,8 @@ paseo plugin update --all
Append `:relative/path` to the source when the plugin lives below the repository root.
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
commits. Removing a Git source deletes Paseo's managed checkout.
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
Removing a Git source deletes Paseo's managed checkout.
### Declare Git preparation
@@ -111,6 +116,7 @@ step:
```json
{
"id": "review",
"requirements": { "paseo": ">=0.8.0" },
"build": [
["npm", "ci"],
["npm", "run", "build"]
@@ -152,22 +158,46 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`.
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
See `public-docs/plugins/v0.8/reference.md`.
| Module | Use it for |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
### SDK import boundaries
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
Classify every SDK export before adding it. All client entry points and implementations live under
`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types,
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary.
React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any
server entry. Node and platform-specific code must never be reachable from the shared root.
The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change
must preserve them and update the public reference, migration guide, scaffold, and examples when
an author-facing import changes. The plugin compiler enforces the same runtime entry rules for
plugin-authored code. Keep the package export map and host-provided module maps consistent.
The compiler rejects imports across runtime directories or SDK entries, React dependencies in
server code, and Node imports in client code (including bare names such as `fs`). Shared modules
cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with
empty module stubs. A relative
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
Native calls such as `StyleSheet.create` belong in `client/`.
@@ -176,12 +206,12 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
pattern.
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetRpc } from "./shared/greeting";
@@ -193,7 +223,7 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Greeting } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
@@ -217,7 +247,7 @@ typed async function. Use the host-provided `@tanstack/react-query` for request
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
@@ -253,48 +283,77 @@ optional client-owned agent and workspace navigation; its absence is the compati
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
Plugins do not receive Expo Router or workspace-layout store access.
## Contribute composer pills
## Lifecycle hooks
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
surface.
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
```tsx
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
of its sessions; plugin RPC is not part of the provider data path.
```ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { createProvider } from "./server/provider";
export default function contribute(server: PluginServerContext) {
server.registerProvider(createProvider() satisfies ProviderRegistration);
return () => {};
}
```
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
`addComposerPill` returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
the host, or unloading the app tears down the contribution.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target. Return a key covering effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `providerOptions` config object.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
daemon-appended plugin items. The direct example includes both sides. The renderer-only
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
## Contribute timeline items
@@ -368,7 +427,7 @@ credentials and vendor API calls stay in the daemon handler.
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";
@@ -380,7 +439,7 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { issues } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
@@ -394,6 +453,18 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
## Contribute settings
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
The host settings shell owns navigation and layout; plugin content must not add another page
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
and `plugin-examples/settings` for the named UI components and persistence API.
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
Its writer lives with the plugin subprocess, while its directory lives outside managed sources,
so updates and reloads retain values. Settings-change notifications must not enter the catalog
reload path: that path disposes the plugin and would destroy open drafts after every save.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
@@ -417,7 +488,7 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
+74 -53
View File
@@ -1,81 +1,102 @@
# Product
What Paseo is, who it's for, and where it's going.
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
## What is Paseo
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
## Agents are the focus
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
## Core philosophy
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
Freedom and flexibility. Every design decision follows from this:
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## A low floor and a high ceiling
## How it works
### Easy to start
### Projects and workspaces
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
### Inside a workspace
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
A workspace is a flexible canvas:
### Room to build your own setup
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
### The daemon
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
This architecture means:
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when a client disconnects — the daemon owns them, not the client
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
## Target user
## Freedom, ownership, and privacy
Anyone who builds software:
Paseo's flexibility depends on keeping control with the user:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
## What compounds over time
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## A lean core that keeps improving
## Strategic bets
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
## Current state (May 2026)
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
- Key UX: split panes, keybinding customization, workspace model, in-app browser
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
## Extensibility and composition
Plugins are a central part of how Paseo gains capability.
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
## How Paseo develops
Product, design, architecture, and workflow decisions remain with the maintainer.
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
- Can people discover and understand it?
- Does it work well in their actual workflows?
- What friction or missing capability appears with repeated use?
- Does the next addition improve the shared experience or belong in a plugin?
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
## An ecosystem people can build on
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
+12
View File
@@ -38,6 +38,18 @@ The app checks for the capability and either runs the feature or tells the user
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Client capability ownership
The client package advertises the protocol behavior it implements. Add each new capability to
its exhaustive defaults and implement the associated subscription or decoding behavior there.
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
automation, or explicit overrides. A schema accepting a message does not establish support for
its delivery semantics.
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+46 -7
View File
@@ -57,7 +57,9 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
@@ -113,7 +115,18 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
Provider snapshots are views of the catalogues needed for a target. Before looking up cached
results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`.
The provider owns equivalence: equal keys must mean the same availability, models and modes,
including the effective configuration and execution environment. `force` does not change identity.
Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's
current host clients share across directories; configured provider identities remain isolated.
The key chooses storage, never execution. Availability and catalogue discovery receive the actual
`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another
target can share its result. Runtime-aware adapters must use that target for both probing and key
resolution. An explicit home-directory workspace is distinct from a semantic global request.
Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider).
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
@@ -122,15 +135,41 @@ aborts the shared refresh signal at the deadline. Providers name active catalog
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration.
Keys are resolved on each read so project configuration can select a different cached catalogue.
Selector opening may read a loading or stale query, but does not force provider probing.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute
another model. Creation reads the caller's host and directory directly; an earlier global snapshot
must not settle a project form's initial selection.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references`
receive directory-to-hash announcements; an unknown hash uses the existing snapshot request.
The manager detaches each fresh result once and publishes its content identity with the entry;
readers share these values read-only. Each target keeps its published snapshot until membership,
content or discovery freshness changes. All affected targets commit before subscribers run;
subscriber failures are logged without changing discovery or configuration outcomes.
The session compares both sides of a committed transition under the client's current visibility
policy and sends only visible changes. It combines result identities with the client's encoding
and icon policy, without traversing models. Reference hashes exclude freshness;
legacy embedded hashes include it. Only responses that send a body compact the catalogue.
Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt`
remains the time the response or announcement was generated. Refresh keeps settled entries visible
until the next result, so unchanged discovery sends freshness without another model body.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate
directory/hash/freshness records under the same byte budget. Missing bodies share a React Query
request; directory queries cancel superseded pulls before accepting pushes. Default SDK clients
keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots.
Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
Settings refresh invalidates requested providers across known targets and refreshes them in their
actual execution context, so every connected client receives the refreshed view. Discovery shares
work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates
only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation
leaves installed reads untouched until commit. Plugin replacement uses registration runtime
identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup
promise for completion: equal results, including equal discovery timestamps, emit no transition.
---
+3
View File
@@ -11,6 +11,9 @@ The bar is four questions:
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before
exercising contributions. Include the boundary tests and both runtime loaders in the evidence.
## Evidence
Evidence is something someone else can look at:
+43 -1
View File
@@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
@@ -64,7 +94,7 @@ release push as the changelog and version commit.
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
@@ -253,6 +283,15 @@ If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its ow
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### macOS system floor
The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes:
- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs.
- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update.
macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest.
### Limitations
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
@@ -477,6 +516,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
## Changelog policy
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
@@ -634,6 +675,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
+11 -5
View File
@@ -65,12 +65,18 @@ Codex hook mapping:
- `PermissionRequest` → `needs-input`
- `Stop` → `idle`
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions.
- `session.status` with `busy` or `retry` → `running`
- `session.status` with `idle` → `idle`
- `permission.asked` → `needs-input`
- `permission.replied` → `running`
| OpenCode event | Generation | Activity |
| ----------------------------------------------------------- | ---------- | ----------- |
| `session.status` with `busy` or `retry` | 1 | running |
| `session.status` with `idle` | 1 | idle |
| `session.execution.started` | 2 | running |
| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle |
| `permission.asked` | Both | needs-input |
| `permission.replied` | Both | running |
The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads.
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
+4 -4
View File
@@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj
live prefix.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
send-now, and the host runtime's automatic queue drain — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
@@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
mounted.
second running state. Disconnect preserves the last replicated turn until cache or network hydration
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
liveness, never from submission records or whichever timeline rows happen to be mounted.
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
+14 -1
View File
@@ -346,9 +346,22 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Patching The Web Runtime
When backporting a Unistyles web fix, patch the TypeScript source and both
shipped JavaScript builds. Native Metro resolves the package's `react-native`
export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
sha256-j8U5S71SJoC+5yh7IjkadrRZaHOYeMsi1z8B6K7j4R4=
+112 -182
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.2",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.2",
"version": "0.8.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -28,6 +28,7 @@
"@vercel/nft": "^1.5.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -2883,6 +2884,16 @@
"node": ">=0.8.0"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -2989,70 +3000,37 @@
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"progress": "^2.0.3",
"semver": "^6.2.0",
"semver": "^7.6.3",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
"node": ">=22.12.0"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
"undici": "^7.24.4"
}
},
"node_modules/@electron/get/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/@electron/get/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/get/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@electron/get/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"node_modules/@electron/get/node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@electron/notarize": {
@@ -13081,6 +13059,13 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -13187,17 +13172,6 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -15813,16 +15787,6 @@
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -18675,22 +18639,22 @@
}
},
"node_modules/electron": {
"version": "41.2.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz",
"integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==",
"version": "44.2.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-44.2.0.tgz",
"integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
"extract-zip": "^2.0.1"
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js"
"electron": "cli.js",
"install-electron": "install.js"
},
"engines": {
"node": ">= 12.20.55"
"node": ">= 22.12.0"
}
},
"node_modules/electron-builder": {
@@ -18797,19 +18761,19 @@
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.18.0"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -21881,43 +21845,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/extract-zip/node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
@@ -22082,16 +22009,6 @@
"walk-up-path": "^4.0.0"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -29636,13 +29553,6 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -34564,7 +34474,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -36137,17 +36046,6 @@
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
@@ -36298,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37261,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.2",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/server": "0.8.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37513,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37527,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.2",
"version": "0.8.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37640,9 +37538,10 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",
"electron": "44.2.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
@@ -37872,7 +37771,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.2",
"version": "0.8.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38667,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,43 +38696,74 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"react-native": "0.81.5",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
}
},
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz",
"integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/semver": "^7.8.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
},
"packages/protocol/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,16 +38981,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"@getpaseo/client": "0.8.0",
"@getpaseo/highlight": "0.8.0",
"@getpaseo/plugin": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39086,6 +39016,7 @@
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -39101,7 +39032,6 @@
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
},
@@ -39938,7 +39868,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.2",
"version": "0.8.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.2",
"version": "0.8.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -126,6 +126,7 @@
"@vercel/nft": "^1.5.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -5,6 +5,7 @@ import {
expectAgentIdle,
expectAgentReadyToInterrupt,
expectAgentSurfacesIdle,
expectInlineWorkingIndicator,
expectRunningAgentChrome,
expectVisibleAgentSurfacesIdle,
} from "../support/helpers/agent-stream";
@@ -549,9 +550,18 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission
const userMessage = await submitMessageWithImage(page, prompt);
await gate.waitForHeldServerMessage();
await gate.waitForAgentStreamItem("user_message");
await gate.waitForAgentStreamEvent("turn_started");
gate.releaseHeldServerMessage();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await gate.drop();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expectInlineWorkingIndicator(page);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setServerMessageSuppressed("agent_status", false);
gate.setServerMessageSuppressed("agent_update", false);
gate.restoreFresh();
await expectVisibleAgentSurfacesIdle(page);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
@@ -991,7 +1001,7 @@ test.describe("Agent message submission", () => {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "Keep running until the queued turn is ready.");
await expectAgentReadyToInterrupt(page);
await expectRunningAgentChrome(page, title);
await queueMessage(page, secondPrompt);
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible();
@@ -216,7 +216,9 @@ test.describe("Agent stream UI", () => {
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await awaitAssistantMessage(page);
// Reasoning rows collapse when the mock starts its next assistant response. Wait past
// that transition so their temporary height cannot satisfy the scroll-away setup.
await awaitAssistantMessage(page, "Now I have a clearer picture.");
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 45_000,
@@ -0,0 +1,86 @@
import { expect, test as base } from "../support/fixtures";
import type { Page, TestInfo } from "@playwright/test";
import {
startIsolatedHostDaemon,
type IsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
reconnectHost: async ({ e2eWorker }, provide) => {
void e2eWorker;
const daemon = await startIsolatedHostDaemon("background-reconnect");
try {
await provide(daemon);
} finally {
await daemon.close();
}
},
});
test.describe.configure({ timeout: 120_000 });
async function captureHost({
page,
testInfo,
name,
}: {
page: Page;
testInfo: TestInfo;
name: string;
}): Promise<void> {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) });
}
async function refocusConnectedTab(page: Page): Promise<void> {
await setTabVisibility(page, "visible");
await expect(page.getByText("Online", { exact: true })).toBeVisible();
}
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
await page.goto("/");
await seedSavedSettingsHosts(page, [
{
serverId: daemon.serverId,
label: "Reconnect QA",
endpoint: `127.0.0.1:${daemon.port}`,
},
]);
await page.goto(`/settings/hosts/${daemon.serverId}/host`);
await expect(page.getByText("Online", { exact: true })).toBeVisible();
}
async function restartHostWhileHidden(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
const reconnectedSocket = page.waitForEvent("websocket", {
timeout: 30_000,
predicate: (socket) => new URL(socket.url()).port === String(daemon.port),
});
await daemon.restart();
await reconnectedSocket;
await expect(page.getByText("Online", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => document.visibilityState)).toBe("hidden");
}
test("a hidden tab reconnects after daemon restart and stays connected on refocus", async ({
page,
reconnectHost,
}, testInfo) => {
await openHostOverview(page, reconnectHost);
await captureHost({ page, testInfo, name: "before-hide" });
await setTabVisibility(page, "hidden");
await restartHostWhileHidden(page, reconnectHost);
await captureHost({ page, testInfo, name: "reconnected-while-hidden" });
await refocusConnectedTab(page);
await captureHost({ page, testInfo, name: "reconnected-after-refocus" });
});
+59 -19
View File
@@ -311,8 +311,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
@@ -397,23 +397,24 @@ test("every interactive file header has the same hover feedback", async ({ page
const first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle");
const canvas = page.getByTestId("git-diff-header-canvas");
const normalBackground = await headerCanvasPixel(canvas, first, 10);
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
await first.hover();
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
await first.click();
await page.mouse.move(0, 0);
await expect(first).toHaveAttribute("aria-expanded", "false");
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
await first.hover();
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
await second.hover();
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
});
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
@@ -422,22 +423,28 @@ test("horizontal body scrolling never moves or repaints the canvas header", asyn
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
const headerCanvas = page.getByTestId("git-diff-header-canvas");
const headerCanvas = page.getByTestId("git-diff-canvas");
const header = page.getByTestId("diff-file-0-toggle");
const beforeImage = await headerCanvas.evaluate((element) =>
(element as HTMLCanvasElement).toDataURL(),
);
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
const beforeBounds = await header.boundingBox();
await horizontallyScrollFirstFile(page, 320);
await page.waitForTimeout(50);
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
beforeImage,
);
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
expect(await header.boundingBox()).toEqual(beforeBounds);
});
test("in-flow file headers move with the diff document", async ({ page }) => {
const workspace = await createWorkspaceWithStickyTransitionDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace);
const motion = await measureInFlowHeaderMotion(page);
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
});
test("the outgoing sticky header hands off without a gap or overlap", async ({
page,
}, testInfo) => {
@@ -1096,7 +1103,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
const root = page.getByTestId("git-diff-canvas-root");
const scroller = page.getByTestId("git-diff-scroll");
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await scroller.evaluate((element) => {
@@ -1106,7 +1113,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
});
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
@@ -2030,6 +2037,39 @@ async function horizontallyScrollFirstFile(page: Page, requestedOffset: number):
return retainedOffset;
}
async function measureInFlowHeaderMotion(
page: Page,
): Promise<{ headerSurface: number; shell: number }> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const headerSurface = document.querySelector<HTMLElement>('[data-testid="git-diff-canvas"]');
const shell = document.querySelector<HTMLElement>('[data-diff-header-path="src/second.ts"]');
if (!headerSurface || !shell) throw new Error("Diff header motion surfaces are unavailable");
const shellDocumentTop =
scroll.scrollTop + shell.getBoundingClientRect().top - scroll.getBoundingClientRect().top;
scroll.scrollTop = Math.max(0, shellDocumentTop - scroll.clientHeight + 80);
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const before = {
headerSurface: headerSurface.getBoundingClientRect().top,
shell: shell.getBoundingClientRect().top,
};
scroll.scrollTop += 120;
const after = {
headerSurface: headerSurface.getBoundingClientRect().top,
shell: shell.getBoundingClientRect().top,
};
return {
headerSurface: after.headerSurface - before.headerSurface,
shell: after.shell - before.shell,
};
});
}
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
const bounds = await header.boundingBox();
if (!bounds) throw new Error("File header has no bounds");
@@ -105,7 +105,7 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
const [header, canvas] = await Promise.all([
panel.getByTestId("diff-file-0").boundingBox(),
panel.getByTestId("git-diff-header-canvas").boundingBox(),
panel.getByTestId("git-diff-sticky-header-0").boundingBox(),
]);
expect(header).not.toBeNull();
expect(canvas).not.toBeNull();
@@ -93,7 +93,10 @@ diffPerfDescribe("Diff canvas performance", () => {
[
root.querySelectorAll('[data-diff-header="true"]').length,
root.querySelectorAll("*").length,
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
root.querySelector('[data-testid="git-diff-canvas"]') &&
root.querySelectorAll('[data-testid^="git-diff-sticky-header-"]').length === 2
? "canvas-pool"
: "dom",
] as const,
);
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
@@ -125,7 +128,7 @@ diffPerfDescribe("Diff canvas performance", () => {
};
});
const headerCanvasMetrics = await page
.getByTestId("git-diff-header-canvas")
.getByTestId("git-diff-sticky-header-0")
.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Header canvas has no 2D context");
@@ -168,6 +171,7 @@ diffPerfDescribe("Diff canvas performance", () => {
);
if (!RUN_HEADER_COMPARISON) {
expect(benchmark.maximumShells).toBeLessThan(120);
expect(benchmark.maximumStickyCanvases).toBe(2);
expect(benchmark.blankHeaderFrames).toBe(0);
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
@@ -435,7 +439,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
}
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(2);
await expect(page.locator("canvas")).toHaveCount(3);
await expect(page.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page
.getByTestId("git-diff-canvas-root")
@@ -527,40 +532,57 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
p95FrameMs: number;
maximumFrameMs: number;
maximumShells: number;
maximumStickyCanvases: number;
blankHeaderFrames: number;
endScrollTop: number;
maximumScrollTop: number;
}> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="git-diff-header-canvas"]',
);
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
const frameDurations: number[] = [];
let maximumShells = 0;
let maximumStickyCanvases = 0;
let blankHeaderFrames = 0;
const sampleHeader = () => {
maximumShells = Math.max(
maximumShells,
document.querySelectorAll('[data-diff-header="true"]').length,
);
const canvases = Array.from(
document.querySelectorAll<HTMLCanvasElement>('[data-testid^="git-diff-sticky-header-"]'),
);
maximumStickyCanvases = Math.max(maximumStickyCanvases, canvases.length);
const scrollBounds = scroll.getBoundingClientRect();
const sampleX = scrollBounds.left + 4;
const sampleY = scrollBounds.top + 10;
const canvas = canvases.find((candidate) => {
const bounds = candidate.getBoundingClientRect();
return (
bounds.width > 0 &&
sampleX >= bounds.left &&
sampleX < bounds.right &&
sampleY >= bounds.top &&
sampleY < bounds.bottom
);
});
if (canvas) {
const context = canvas.getContext("2d")!;
const ratio = canvas.width / canvas.getBoundingClientRect().width;
const bounds = canvas.getBoundingClientRect();
const ratio = canvas.width / bounds.width;
if (
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
context.getImageData(
Math.round((sampleX - bounds.left) * ratio),
Math.round((sampleY - bounds.top) * ratio),
1,
1,
).data[3] === 0
) {
blankHeaderFrames += 1;
}
return;
}
const bounds = scroll.getBoundingClientRect();
if (
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
) {
blankHeaderFrames += 1;
}
blankHeaderFrames += 1;
};
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
@@ -601,6 +623,7 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
p95FrameMs: percentile(0.95),
maximumFrameMs: Math.max(...frameDurations),
maximumShells,
maximumStickyCanvases,
blankHeaderFrames,
endScrollTop: scroll.scrollTop,
maximumScrollTop,
@@ -0,0 +1,23 @@
import { test } from "../support/fixtures";
import {
withExplorerPanelPlugins,
expectWorkspacePanelsInExplorerLauncher,
openWorkspacePanelFromExplorerMenu,
expectIndependentExplorerPanelToggles,
expectExplorerPanelWithFocusedAgent,
} from "../support/helpers/explorer-plugin-menu";
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
page,
}, testInfo) => {
await withExplorerPanelPlugins(page, async (workspace) => {
await test.step("the New Tab launcher offers compatible workspace panels", () =>
expectWorkspacePanelsInExplorerLauncher(page));
await test.step("the rail menu offers the same workspace panels", () =>
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
await test.step("different panels and plugins keep independent selection and closing", () =>
expectIndependentExplorerPanelToggles(page, workspace));
await test.step("focusing an agent does not add agent panels to Explorer", () =>
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
});
});
@@ -1,4 +1,6 @@
import { expect, test, type Page } from "../support/fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -49,30 +51,52 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "codex-mode-preferences",
});
try {
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd })).entries.find(
(entry) => entry.provider === "codex",
)?.status,
)
.toBe("ready");
const snapshot = await client.getProvidersSnapshot({ cwd });
const model = snapshot.entries
.find((entry) => entry.provider === "codex")
?.models?.find((candidate) => candidate.thinkingOptions?.length);
if (!model?.thinkingOptions?.[0])
throw new Error("Codex catalogue must expose a model with thinking options");
await page.addInitScript(
({ preferencesKey, modelId, thinkingOptionId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
provider: "codex",
providerPreferences: {
codex: {
model: modelId,
mode: "auto",
thinkingByModel: { [modelId]: thinkingOptionId },
},
mock: { model: "ten-second-stream" },
},
mock: {
model: "ten-second-stream",
},
},
} satisfies FormPreferences),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
);
} satisfies FormPreferences),
);
},
{
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
modelId: model.id,
thinkingOptionId: model.thinkingOptions[0].id,
},
);
return model.id;
} finally {
await client.close();
}
}
async function readCodexModePreference(page: Page): Promise<unknown> {
@@ -156,9 +180,8 @@ test.describe("New workspace Codex mode preferences", () => {
}) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page);
try {
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
@@ -193,16 +216,15 @@ test.describe("New workspace Codex mode preferences", () => {
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page);
try {
const model = await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Codex live mode preference e2e",
modeId: "auto",
model: "gpt-5.4-mini",
model,
});
await openAgentRoute(page, {
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
// Wait for input until project cleanup kills the terminal. A fixed lifetime
// can expire before a busy browser attaches or finishes revisiting the composer.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -25,14 +25,13 @@ import {
//
// The sentinel becomes `$0` (a positional shell parameter, not string
// interpolation into the script source), so the prompt reaches the process
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
// long enough for the UI to attach and render the output before it exits —
// a bare `echo` exits before the frontend subscribes to its output.
// exactly as typed with no injection risk. `cat` waits for input until project
// cleanup kills the terminal, so attachment does not race a fixed process lifetime.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-echo-prompt",
name: "Echo Prompt",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"],
};
// No sentinel anywhere: takes no prompt, composer goes read-only.
@@ -40,7 +39,7 @@ const BARE_PROFILE: TerminalProfile = {
id: "e2e-echo-bare",
name: "Bare Echo",
command: "/bin/sh",
args: ["-c", "echo bare-launch-static-line; sleep 10"],
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
test.describe("New workspace: launching a terminal", () => {
@@ -88,7 +87,7 @@ test.describe("New workspace: launching a terminal", () => {
});
await selectLaunchOption(page, BARE_PROFILE.id);
await expect(terminalPromptInput(page)).toHaveCount(0);
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
});
@@ -0,0 +1,69 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
chooseModel,
reselectModel,
startWithoutRememberedModel,
expectCreatedModelAgents,
expectRememberedModel,
expectSavedSelection,
} from "../support/helpers/model-memory";
import {
openGlobalNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
test("remembers real Codex GPT-6 Astra after repeated successful workspace creation", async ({
page,
}) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "codex-astra-memory-" });
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "codex-astra-memory" });
try {
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd: workspace.repoPath })).entries.find(
(entry) => entry.provider === "codex",
)?.status,
{ timeout: 60_000 },
)
.toBe("ready");
const snapshot = await client.getProvidersSnapshot({ cwd: workspace.repoPath });
const codex = snapshot.entries.find((entry) => entry.provider === "codex");
expect(codex?.status).toBe("ready");
const astra = codex?.models?.find((model) => model.id === "gpt-6-astra");
expect(astra?.id).toBe("gpt-6-astra");
const label = astra!.label;
await startWithoutRememberedModel(page);
await gotoWorkspace(page, workspace.workspaceId);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
for (const [index, select] of [chooseModel, reselectModel].entries()) {
const count = index + 1;
await test.step(`create workspace ${count} and return`, async () => {
await select(page, "codex", label);
await expectSavedSelection(page, "codex", "gpt-6-astra");
await submitNewWorkspacePrompt(page, "Reply with OK. Do not use tools or change files.");
await expect(page).toHaveURL(/\/workspace\//);
await expectCreatedModelAgents(page, client, "codex", "gpt-6-astra", count);
await expect(
page.getByTestId("assistant-message").filter({ hasText: "OK", visible: true }),
).toBeVisible({
timeout: 60_000,
});
await openGlobalNewWorkspaceComposer(page);
await expectSavedSelection(page, "codex", "gpt-6-astra");
await expectRememberedModel(page, label);
});
}
await page.screenshot({ path: test.info().outputPath("astra-remembered.png") });
} finally {
await client.close();
await workspace.cleanup();
}
});
@@ -0,0 +1,109 @@
import { mkdtemp, rename, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect } from "../support/fixtures";
import { seedModelProvider } from "../support/helpers/agent-profiles";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openGlobalNewWorkspaceComposer,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
chooseModel,
reselectModel,
startWithoutRememberedModel,
expectSavedSelection,
expectCreatedModelAgents,
expectRememberedModel,
} from "../support/helpers/model-memory";
const PROVIDER = "model-memory-diagnostic";
const MODEL = "pi-profile-model";
const LABEL = "Pi profile model";
async function expectProviderStatus(client: DaemonClient, cwd: string | undefined, status: string) {
await expect
.poll(
async () =>
(await client.getProvidersSnapshot(cwd ? { cwd } : undefined)).entries.find(
(entry) => entry.provider === PROVIDER,
)?.status,
{ timeout: 30_000 },
)
.toBe(status);
}
async function prepareCatalogState(
client: DaemonClient,
executable: string,
cwd: string,
hostStatus: "ready" | "unavailable",
) {
await expectProviderStatus(client, undefined, "ready");
await expectProviderStatus(client, cwd, "ready");
await rename(executable, `${executable}.parked`);
await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
await expectProviderStatus(client, undefined, "unavailable");
await rename(`${executable}.parked`, executable);
if (hostStatus === "ready") await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
await client.refreshProvidersSnapshot({ cwd, providers: [PROVIDER] });
await expectProviderStatus(client, cwd, "ready");
await expectProviderStatus(client, undefined, hostStatus);
}
for (const hostStatus of ["ready", "unavailable"] as const) {
test(`remembers the manually selected model after two creations with a ${hostStatus} host catalog and ready project catalog`, async ({
page,
}) => {
test.setTimeout(150_000);
const workspace = await seedWorkspace({ repoPrefix: "model-memory-scope-" });
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-model-memory-bin-"));
const executable = path.join(binDir, "provider-node");
await symlink(process.execPath, executable);
const provider = await seedModelProvider({
id: PROVIDER,
label: "Model memory diagnostic",
extends: "pi",
command: [executable, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
models: [{ id: MODEL, label: LABEL, description: "Remembered selection diagnostic" }],
});
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-memory" });
try {
await prepareCatalogState(client, executable, workspace.repoPath, hostStatus);
await startWithoutRememberedModel(page);
await gotoWorkspace(page, workspace.workspaceId);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
for (const [index, select] of [chooseModel, reselectModel].entries()) {
const count = index + 1;
await test.step(`create workspace ${count} and return`, async () => {
await select(page, PROVIDER, LABEL);
await expectSavedSelection(page, PROVIDER, MODEL);
await submitNewWorkspacePrompt(page, "Remember this model on the next workspace");
await expect(page).toHaveURL(/\/workspace\//);
await expectCreatedModelAgents(page, client, PROVIDER, MODEL, count);
await openGlobalNewWorkspaceComposer(page);
await expectSavedSelection(page, PROVIDER, MODEL);
await expectRememberedModel(page, LABEL);
});
}
await submitNewWorkspaceEmpty(page);
await expect(page).toHaveURL(/\/workspace\//);
await openGlobalNewWorkspaceComposer(page);
await expectSavedSelection(page, PROVIDER, MODEL);
await expectRememberedModel(page, LABEL);
} finally {
await client.close();
await provider.restore();
await workspace.cleanup();
await rm(binDir, { recursive: true, force: true });
}
});
}
@@ -0,0 +1,102 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect, type Page } from "../support/fixtures";
import {
closeModelPicker,
drillIntoProvider,
openModelPicker,
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { gotoWorkspace } from "../support/helpers/launcher";
import { openGlobalNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { expectRememberedModel, expectSavedSelection } from "../support/helpers/model-memory";
const PROVIDER = "remembered-model-recovery";
const MODEL = "remembered-model";
const LABEL = "Remembered model";
async function rememberModel(page: Page) {
await page.addInitScript(
({ provider, model }) => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider,
providerPreferences: { [provider]: { model } },
}),
);
},
{ provider: PROVIDER, model: MODEL },
);
}
async function expectRecoveredModelInPicker(page: Page) {
await openModelPicker(page);
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
await drillIntoProvider(page, PROVIDER);
await expect(
page.getByTestId("combobox-desktop-container").getByText(LABEL, { exact: true }),
).toBeVisible();
await closeModelPicker(page);
}
async function setProviderAvailability(client: DaemonClient, cwd: string, available: boolean) {
await client.patchDaemonConfig({
providers: {
[PROVIDER]: {
command: [available ? process.execPath : "/missing-paseo-diagnostic-provider"],
},
},
});
for (const scope of [undefined, cwd]) {
await expect
.poll(
async () => {
const snapshot = await client.getProvidersSnapshot(scope ? { cwd: scope } : undefined);
return snapshot.entries.find((entry) => entry.provider === PROVIDER)?.status;
},
{ timeout: 30_000 },
)
.toBe(available ? "ready" : "unavailable");
}
}
test("restores the remembered model when a provider recovers after New workspace opens", async ({
page,
}) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "model-recovery-" });
const provider = await seedModelProvider({
id: PROVIDER,
label: "Remembered provider",
models: [{ id: MODEL, label: LABEL, description: "Model restoration diagnostic" }],
command: [process.execPath],
});
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-recovery" });
try {
await rememberModel(page);
await gotoWorkspace(page, workspace.workspaceId);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectRememberedModel(page, LABEL);
await gotoWorkspace(page, workspace.workspaceId);
await waitForSidebarHydration(page);
await setProviderAvailability(client, workspace.repoPath, false);
await openGlobalNewWorkspaceComposer(page);
await expect(
page.getByTestId("combined-model-selector").filter({ visible: true }),
).not.toContainText("Loading...");
await setProviderAvailability(client, workspace.repoPath, true);
await expectSavedSelection(page, PROVIDER, MODEL);
await expectRecoveredModelInPicker(page);
await expectRememberedModel(page, LABEL);
} finally {
await client.close();
await provider.restore();
await workspace.cleanup();
}
});
@@ -0,0 +1,22 @@
import { test } from "../support/fixtures";
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
for (const launch of ["chat", "terminal", "empty"] as const) {
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave");
});
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
});
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "stay");
});
}
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
});
});
@@ -0,0 +1,13 @@
import { test } from "../support/fixtures";
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
test("the button author example shows each capability without a competing overflow button", async ({
page,
}, info) => {
test.setTimeout(120_000);
await withButtonAuthorExample(page, info, async (buttons) => {
await buttons.refreshFromHeaderAndComposer();
await buttons.useMenusAndToggleButtons();
await buttons.inspectLiveWorkspaceDetails();
});
});
@@ -0,0 +1,14 @@
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await withButtonShowcase(page, testInfo, async (buttons) => {
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.hideAndUnloadOpenButtons();
});
});
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -11,8 +12,8 @@ import {
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -118,7 +119,10 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
try {
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -120,7 +121,10 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try {
@@ -0,0 +1,163 @@
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
openMobileAgentSidebar,
expectMobileAgentSidebarVisible,
} from "../support/helpers/sidebar";
async function openExample(page: Page, example: string) {
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible();
}
async function closeExample(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
async function copyAndPaste(page: Page) {
await page.getByRole("button", { name: "Copy text", exact: true }).click();
await expect(page.getByText("Text copied", { exact: true })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo");
await page.getByRole("textbox", { name: "Paste here" }).focus();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("ControlOrMeta+V");
await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo");
}
async function scrollToLastRow(page: Page) {
await page.getByRole("button", { name: "Row 1", exact: true }).hover();
await page.mouse.wheel(0, 7000);
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
await page.getByRole("button", { name: "Row 100", exact: true }).click();
}
async function expectSingleInset(page: Page, left = "24px", bottom = "24px") {
const form = await page.getByTestId("modal-example-form").boundingBox();
const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox();
expect(form).not.toBeNull();
expect(input?.x).toBe(form?.x);
// The real content View owns the inset, not a third-party contentContainerStyle.
const padding = await page.getByTestId("modal-example-form").evaluate((node) => {
const content = node.parentElement!;
return {
left: getComputedStyle(content).paddingLeft,
bottom: getComputedStyle(content).paddingBottom,
};
});
expect(padding).toEqual({ left, bottom });
}
async function expectFullBleed(page: Page) {
const padding = await page
.getByRole("button", { name: "Row 1", exact: true })
.evaluate((node) => {
const content = node.parentElement!;
return {
left: getComputedStyle(content).paddingLeft,
bottom: getComputedStyle(content).paddingBottom,
};
});
expect(padding).toEqual({ left: "0px", bottom: "0px" });
}
async function exerciseBodies(page: Page) {
await openExample(page, "Form");
await expectSingleInset(page);
await copyAndPaste(page);
await closeExample(page);
await openExample(page, "Custom inset");
await expectSingleInset(page, "8px", "40px");
await closeExample(page);
await openExample(page, "Full bleed");
await expectFullBleed(page);
await scrollToLastRow(page);
await closeExample(page);
await openExample(page, "ScrollView");
await scrollToLastRow(page);
await closeExample(page);
await openExample(page, "FlatList");
await page.getByRole("button", { name: "Jump to last row", exact: true }).click();
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
await closeExample(page);
await openExample(page, "Horizontal");
await page.getByRole("button", { name: "Overview", exact: true }).hover();
await page.mouse.wheel(900, 0);
await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport();
await page.getByRole("button", { name: "System logs", exact: true }).click();
await closeExample(page);
await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible();
}
const test = base.extend<{ modalExample: Page }>({
modalExample: async ({ page, context }, provide) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("modal-ui");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await provide(page);
} finally {
await client.removePlugin("modal-ui-example").catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await example.cleanup();
}
},
});
async function openWideExamples(page: Page) {
await page.setViewportSize({ width: 1100, height: 800 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
}
async function openCompactExamples(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
}
async function expectDeniedCopyFeedback(page: Page) {
const cdp = await page.context().newCDPSession(page);
try {
const { targetInfo } = await cdp.send("Target.getTargetInfo");
await cdp.send("Browser.setPermission", {
browserContextId: targetInfo.browserContextId,
permission: { name: "clipboard-write" },
setting: "denied",
origin: new URL(page.url()).origin,
});
expect(
await page.evaluate(
async () =>
(await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state,
),
).toBe("denied");
await openExample(page, "Form");
await page.getByRole("button", { name: "Copy text", exact: true }).click();
await expect(
page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }),
).toBeVisible();
await closeExample(page);
} finally {
await cdp.detach();
}
}
test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({
modalExample: page,
}) => {
await openWideExamples(page);
await test.step("wide dialogs", () => exerciseBodies(page));
await openCompactExamples(page);
await test.step("compact sheets", () => exerciseBodies(page));
await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page));
});
@@ -0,0 +1,16 @@
import {
test,
verifyProviderSettings,
verifyNewWorkspaceModelIcon,
verifyCompactModelIcon,
verifyExistingAgentModelIcon,
} from "../support/helpers/plugin-provider-icons";
test("plugin provider icons follow the model through settings and wide and compact composers", async ({
providerIcons,
}) => {
await verifyProviderSettings(providerIcons);
await verifyNewWorkspaceModelIcon(providerIcons);
await verifyCompactModelIcon(providerIcons);
await verifyExistingAgentModelIcon(providerIcons);
});
@@ -0,0 +1,18 @@
import {
test,
openRequirementHost,
expectAppMismatch,
correctRequirementAndReload,
rejectDaemonMismatch,
} from "../support/helpers/plugin-requirements";
test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({
page,
requirementHost,
}) => {
await openRequirementHost(page, requirementHost);
await expectAppMismatch(page);
await correctRequirementAndReload(page, requirementHost.directory);
await rejectDaemonMismatch(page, requirementHost.directory);
await correctRequirementAndReload(page, requirementHost.directory);
});
@@ -0,0 +1,62 @@
import {
test,
openDisplaySettings,
openCompactDisplaySettings,
groupByWorkspace,
hideMetadata,
reloadSavedDisplaySettings,
editTitle,
replaceTitle,
saveTitle,
expectTitleRequired,
expectSavedTitle,
changeMetadataFromAnotherClient,
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
await openDisplaySettings(page);
await groupByWorkspace(page);
await hideMetadata(page);
await reloadSavedDisplaySettings(page);
});
test("invalid titles stay editable until a valid title is saved", async ({ page }) => {
await openDisplaySettings(page);
await editTitle(page, "");
await saveTitle(page);
await expectTitleRequired(page);
await replaceTitle(page, "My monitor");
await saveTitle(page);
await expectSavedTitle(page, "My monitor");
});
test("another client's save preserves a conflicting draft", async ({ page }) => {
await openDisplaySettings(page);
await editTitle(page, "Unsaved title");
await changeMetadataFromAnotherClient(page);
await saveTitle(page);
await expectConflictPreservesDraft(page, "Unsaved title");
await discardTitle(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("Back and plugin re-enable restore the settings screen", async ({
page,
settingsPlugin,
}) => {
await openCompactDisplaySettings(page);
await groupByWorkspace(page);
await hideMetadata(page);
await returnAndReopenSettings(page);
await settingsPlugin.disablePlugin("settings-example");
await expectSettingsUnavailable(page);
await settingsPlugin.enablePlugin("settings-example");
await reloadSavedDisplaySettings(page);
});
});
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -53,7 +54,10 @@ test("applies a contributed theme and falls back when its plugin is gone", async
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -29,7 +30,8 @@ function isSettledWorkspaceUrl(url: URL): boolean {
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
@@ -59,12 +61,6 @@ function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function ComposerPill({ theme, workspaceId, agentId }) {
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
const agent = useAgent(agentId, (value) => ({ title: value.title }));
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
@@ -79,23 +75,25 @@ function contributeClient(client) {
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
let removePill = () => {};
removePill = client.addComposerPill({
const pill = client.addComposerPill({
id: "review",
title: "Open composer review",
workspaceId: agent.workspaceId,
agentId: agent.id,
Component: ComposerPill,
async onPress() {
button: {
title: "Open composer review",
icon: "Scan",
label: "Review",
behavior: { kind: "action", async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
removePill();
pill.remove();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
} },
},
});
pills.set(agent.id, removePill);
pills.set(agent.id, () => pill.remove());
});
return () => {
unsubscribe();
@@ -199,7 +197,10 @@ test.describe("plugin workspace panels and Command Center", () => {
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
@@ -328,9 +329,7 @@ test.describe("plugin workspace panels and Command Center", () => {
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText(
"Review Unrelated title update:Plugin panel context agent",
);
await expect(composerPill).toContainText("Review");
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
@@ -9,6 +9,11 @@ import {
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
import {
expectNestedProviderSubagentOwnership,
launchNestedProviderSubagentOwnershipScenario,
reopenNestedProviderSession,
} from "../support/helpers/provider-subagents";
import { openSubagentsTrack } from "../support/helpers/subagents";
interface ProviderSubagentCase {
@@ -26,7 +31,7 @@ const cases: ProviderSubagentCase[] = [
provider: "claude",
sentinel: "CLAUDE_CHILD_SENTINEL",
expectedName: "sentinel_child",
providerConfig: { model: "opus" },
providerConfig: { model: "claude-sonnet-5" },
prompt:
'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.',
},
@@ -147,3 +152,23 @@ test.describe("real provider subagent timelines", () => {
});
}
});
test.describe("real Claude nested subagent ownership", () => {
test.setTimeout(600_000);
test("keeps a grandchild and its background notification with their direct owners", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ui-")));
let handle: AgentHandle | undefined;
try {
handle = await launchNestedProviderSubagentOwnershipScenario(page, cwd);
await expectNestedProviderSubagentOwnership(page, testInfo, "live");
await reopenNestedProviderSession(handle);
await expectNestedProviderSubagentOwnership(page, testInfo, "reopened");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
+48 -5
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { openSettingsSection } from "../support/helpers/settings";
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
const DISCORD_DESTINATION =
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
@@ -41,7 +42,7 @@ async function expectExternalPage(
await popup.close();
}
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
test("opens troubleshooting and support destinations", async ({ page }) => {
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-help")).toBeVisible();
@@ -62,18 +63,60 @@ test("opens troubleshooting, support, and release destinations", async ({ page }
await closeSheet(page, "keyboard-shortcuts-dialog");
});
await test.step("opens support and release pages", async () => {
await test.step("opens support pages", async () => {
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
});
test("renders the changelog in the app and links the website", async ({ page }) => {
// A callout, a section name the app has never seen, and a fenced sample whose
// contents look like a release heading.
await serveChangelog(page, [
"# Changelog",
"",
"## 9.1.0 - 2026-03-04",
"",
"Headline release note.",
"",
"> [!WARNING]",
"> Read this before upgrading.",
"",
"### Sparkles",
"",
"- Added a brand new thing",
"",
"```md",
"## 0.0.0 - 1999-01-01",
"```",
"",
"## 9.0.0 - 2026-02-01",
"",
"### Fixed",
"",
"- Fixed an older thing",
"",
]);
await gotoAppShell(page);
const sheet = await openWhatsNew(page);
const latest = release(sheet, "9.1.0");
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
await expect(latest.getByText("Headline release note.")).toBeVisible();
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
await expect(release(sheet, "9.0.0")).toBeVisible();
await expect(release(sheet, "0.0.0")).toHaveCount(0);
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
await closeSheet(page, "changelog-sheet");
});
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
@@ -0,0 +1,229 @@
import type { Page } from "@playwright/test";
import { test as base, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
interface FinishedWorkspaces {
subject: MockAgentWorkspace;
other: MockAgentWorkspace;
}
const test = base.extend<{ workspaces: FinishedWorkspaces }>({
workspaces: async ({ browserName: _browserName }, provide) => {
const seeded: MockAgentWorkspace[] = [];
async function finishedWorkspace(title: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "workspace-mark-unread-",
title,
initialPrompt: "Finish this turn.",
});
seeded.push(workspace);
await workspace.client.waitForFinish(workspace.agentId, 20_000);
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
return workspace;
}
try {
await provide({
subject: await finishedWorkspace("Unread subject"),
other: await finishedWorkspace("Other workspace"),
});
} finally {
for (const workspace of seeded) await workspace.cleanup();
}
},
});
function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
async function openWorkspace(page: Page, workspaceId: string) {
await workspaceRow(page, workspaceId).click();
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
}
async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") {
await workspaceRow(page, workspaceId).hover();
await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click();
const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true });
await expect(item).toBeVisible();
await item.click();
}
async function markAsUnread(page: Page, workspaceId: string) {
await chooseReadAction(page, workspaceId, "unread");
await expectStatus(page, workspaceId, "attention");
}
async function markAsRead(page: Page, workspaceId: string) {
await chooseReadAction(page, workspaceId, "read");
await expectStatus(page, workspaceId, "done");
}
async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") {
await expect(
workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`),
).toBeVisible();
}
async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) {
await test.step("background workspace gains green dot and clears when clicked", async () => {
await markAsUnread(page, workspaceId);
await openWorkspace(page, workspaceId);
await expectStatus(page, workspaceId, "done");
});
}
async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("leaving preserves manual unread until Mark as read", async () => {
await markAsUnread(page, subject.workspaceId);
await openWorkspace(page, other.workspaceId);
await markAsRead(page, subject.workspaceId);
await openWorkspace(page, subject.workspaceId);
});
}
async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("leaving preserves manual unread until reopening", async () => {
await markAsUnread(page, subject.workspaceId);
await openWorkspace(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
async function openWorkspaceOnCompact(page: Page, workspaceId: string) {
await openWorkspace(page, workspaceId);
await openMobileAgentSidebar(page);
}
async function leaveMarkedWorkspaceAndReadOnCompact(
page: Page,
{ subject, other }: FinishedWorkspaces,
) {
await test.step("leaving preserves manual unread until Mark as read", async () => {
await markAsUnread(page, subject.workspaceId);
await openWorkspaceOnCompact(page, other.workspaceId);
await markAsRead(page, subject.workspaceId);
await openWorkspaceOnCompact(page, subject.workspaceId);
});
}
async function leaveMarkedWorkspaceAndReopenOnCompact(
page: Page,
{ subject, other }: FinishedWorkspaces,
) {
await test.step("leaving preserves manual unread until reopening", async () => {
await markAsUnread(page, subject.workspaceId);
await openWorkspaceOnCompact(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await closeMobileAgentSidebar(page);
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await openMobileAgentSidebar(page);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) {
await test.step("explicit Mark as read clears a background workspace", async () => {
await markAsUnread(page, workspaceId);
await markAsRead(page, workspaceId);
});
}
async function expectSelectedAgent(page: Page, agentId: string) {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute(
"aria-selected",
"true",
);
}
async function addFinishedAgent(workspace: MockAgentWorkspace) {
return workspace.client.createAgent({
provider: "mock",
cwd: workspace.cwd,
workspaceId: workspace.workspaceId,
title: "Newest agent",
modeId: "load-test",
model: "e2e-fast-stream",
});
}
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, workspace);
await openMobileAgentSidebar(page);
}
async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
await test.step("interacting with the visible chat clears manual unread", async () => {
await markAsUnread(page, workspaceId);
await closeMobileAgentSidebar(page);
await page.getByRole("textbox", { name: "Message agent..." }).click();
await openMobileAgentSidebar(page);
await expectStatus(page, workspaceId, "done");
});
}
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
page,
workspaces,
}) => {
await gotoAppShell(page);
await openWorkspace(page, workspaces.other.workspaceId);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await leaveMarkedWorkspaceAndRead(page, workspaces);
await leaveMarkedWorkspaceAndReopen(page, workspaces);
await completeTurnAndLeave(page, workspaces);
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
});
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expectSelectedAgent(page, workspaces.subject.agentId);
await openWorkspace(page, workspaces.other.workspaceId);
const newest = await addFinishedAgent(workspaces.subject);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, newest.id);
});
test("manual unread survives leaving the current workspace on compact layout", async ({
page,
workspaces,
}) => {
await openCompactWorkspace(page, workspaces.subject);
await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces);
await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces);
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
await completeTurnAndLeaveOnCompact(page, workspaces);
});
@@ -14,6 +14,7 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
@@ -48,6 +49,20 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
return row;
}
async function openWorkspaceReadAction(
page: import("@playwright/test").Page,
workspaceId: string,
action: "read" | "unread",
) {
const workspaceKey = `${getServerId()}:${workspaceId}`;
const row = await waitForSidebarWorkspace(page, workspaceId);
await row.hover();
await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click();
const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`);
await expect(item).toBeVisible({ timeout: 30_000 });
return item;
}
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
const row = await waitForSidebarWorkspace(page, workspaceId);
await row.hover();
@@ -187,6 +202,32 @@ test.describe("Sidebar workspace list", () => {
}
});
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "sidebar-mark-unread-",
title: "Mark unread",
initialPrompt: "Finish this test turn.",
});
try {
await workspace.client.waitForFinish(workspace.agentId, 20_000);
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true);
await gotoAppShell(page);
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible();
await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click();
await openWorkspaceReadAction(page, workspace.workspaceId, "read");
await page.keyboard.press("Escape");
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await openWorkspaceReadAction(page, workspace.workspaceId, "unread");
} finally {
await workspace.cleanup();
}
});
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
await gotoAppShell(page);
@@ -0,0 +1,86 @@
import { test, expect, type Page } from "../support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
function terminalTab(page: Page, terminalId: string) {
// Terminal tab chips have no tab role; their persistent identity survives title loading.
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).filter({ visible: true }).first();
}
async function createNamedTerminal(page: Page, workspace: SeededWorkspace, title: string) {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const list = () =>
workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
await expect.poll(async () => (await list()).terminals.length).toBe(1);
const terminalId = (await list()).terminals[0]!.id;
await terminalTab(page, terminalId).click({ button: "right" });
await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
const modal = `workspace-tab-rename-modal-terminal-${terminalId}`;
await renameModalInput(page, modal).fill(title);
await renameModalSubmit(page, modal).click();
await expect(renameModalInput(page, modal)).toHaveCount(0);
await expect(terminalTab(page, terminalId)).toHaveText(title);
return terminalId;
}
async function recordRestoredTabText(page: Page, terminalId: string) {
// Install before the next document loads: a final-state assertion would miss the flash.
await page.addInitScript((id) => {
const samples: string[] = [];
Object.assign(window, { terminalTitleSamples: samples });
new MutationObserver(() => {
const tab = document.querySelector(`[data-testid="workspace-tab-terminal_${id}"]`);
if (!tab) return;
const text = (tab.textContent ?? "").trim();
if (samples.at(-1) !== text) samples.push(text);
}).observe(document, { subtree: true, childList: true, characterData: true });
}, terminalId);
}
async function expectRestoredTitle(page: Page, terminalId: string, title: string, stage: string) {
await expect(terminalTab(page, terminalId)).toHaveText(title, { timeout: 30_000 });
const samples = await page.evaluate(
() => (window as unknown as { terminalTitleSamples: string[] }).terminalTitleSamples,
);
await test.info().attach(`${stage}-tab-text`, {
body: JSON.stringify(samples),
contentType: "application/json",
});
expect(samples).toContain(title);
expect(samples).not.toContain("Terminal");
}
test("restored terminal titles do not flash a default name while loading", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "terminal-title-loading-", git: false });
const other = await seedWorkspace({ repoPrefix: "terminal-title-other-", git: false });
try {
const title = "My named terminal";
const terminalId = await createNamedTerminal(page, workspace, title);
await recordRestoredTabText(page, terminalId);
await test.step("reload with a terminal tab restored", async () => {
await page.reload();
await expectRestoredTitle(page, terminalId, title, "reload");
});
await test.step("first workspace visit in a fresh page session", async () => {
await gotoWorkspace(page, other.workspaceId);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
await expectRestoredTitle(page, terminalId, title, "first-visit");
});
await test.step("return to the workspace with its titles already loaded", async () => {
await selectWorkspaceInSidebar(page, other.workspaceId);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
await expectRestoredTitle(page, terminalId, title, "return-visit");
});
} finally {
await workspace.cleanup();
await other.cleanup();
}
});
@@ -4,7 +4,7 @@ import { expect, type Page } from "@playwright/test";
import { metroTest as test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
createIdleAgent,
createMockIdleAgent,
expectSessionRowArchived,
openSessions,
} from "../support/helpers/archive-tab";
@@ -97,7 +97,7 @@ test.describe("Worktree restore after daemon restart", () => {
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
const agent = await createMockIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
@@ -0,0 +1,47 @@
# Native sheet gestures
Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded.
Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there.
Set `SHEET_QA_SERVER_ID` to that host's server ID, then run from the repository root:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/gestures.android.ad \
--env EXAMPLES_URL="paseo://h/${SHEET_QA_SERVER_ID}/plugin/modal-ui-example/surface/main" \
--record-video --artifacts-dir .dev/sheet-qa/plugin
agent-device test packages/app/e2e/mobile/modal-sheet/model.android.ad \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--record-video --artifacts-dir .dev/sheet-qa/model
```
The plugin journey checks body dismissal, reopening, expansion and last-row reachability with SDK
ScrollView and FlatList, programmatic scrolling after expansion, downward list scrolling, and
horizontal tab selection. The model journey checks body dismissal and the nested sheet's return to
its parent without selecting a model or submitting a prompt. It does not assert catalog contents.
These scripts live outside the default mobile suite because they require the installed example and
an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup.
## Recorded verification
Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript.
Plugin tests used an isolated daemon. Recordings show the same body drag
[before](evidence/android-before.mp4) and [after](evidence/android-after.mp4) the fix.
The [browser recording](evidence/browser.webm) covers the existing plugin modal journey at wide and
compact sizes.
```text
Baseline dismissal regression:
failed at step 6: wait timed out for selector: label="Open ScrollView"
Current surface: Bottom sheet handle, Bottom Sheet, Close, Row 1.
Fixed dismissal regression: 1 passed (6.47s)
Full plugin gesture journey: 1 passed (44.3s)
Model sheet dismissal/reopen journey: 1 passed (13.7s)
Browser plugin-modal-body.spec.ts: 1 passed (33.4s)
Root typecheck, lint and format: passed
```
The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's
provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection
was not. iOS and Electron were not exercised.
@@ -0,0 +1,52 @@
# Install plugin-examples/modal-ui on the connected host and set EXAMPLES_URL to
# paseo://h/<serverId>/plugin/modal-ui-example/surface/main.
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
context platform=android timeout=120000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}" "${EXAMPLES_URL}"
wait "label=\"Open ScrollView\"" 20000
press "label=\"Open ScrollView\""
wait "label=\"Row 1\"" 10000
gesture pan 540 980 0 820 500
wait "label=\"Open ScrollView\"" 3000
# The caller-owned ScrollView expands with a body drag and reaches its final row.
press "label=\"Open ScrollView\""
wait "label=\"Row 1\"" 10000
gesture pan 540 980 0 -600 500
wait "label=\"Row 10\"" 3000
scroll bottom
wait "label=\"Row 100\"" 3000
press "role=\"button\" label=\"Row 100\""
press "label=\"Close\""
wait "text=\"Selected: Row 100\"" 3000
# FlatList has the same body dismissal, then supports refs after expansion.
press "label=\"Open FlatList\""
wait "label=\"Row 1\"" 10000
gesture pan 540 1100 0 700 500
wait "label=\"Open FlatList\"" 3000
press "label=\"Open FlatList\""
wait "label=\"Row 1\"" 10000
gesture pan 540 1140 0 -750 500
wait "label=\"Row 9\"" 3000
press "label=\"Jump to last row\""
wait "label=\"Row 100\"" 3000
# Dragging down while scrolled moves the list, leaving the sheet open.
gesture pan 540 650 0 900 500
wait "label=\"Row 88\"" 3000
is visible "label=\"Close\""
press "label=\"Close\""
wait "label=\"Open Horizontal\"" 3000
# Horizontal tabs still receive gestures inside the host-owned vertical body.
press "label=\"Open Horizontal\""
wait "label=\"Overview\"" 10000
gesture pan 960 980 -880 0 500
wait stable 300 5000
press "role=\"button\" label=\"System logs\""
wait stable 300 5000
press "label=\"Close\""
wait "text=\"Selected: System logs\"" 3000
close
@@ -0,0 +1,22 @@
# FORM_URL is paseo://new?serverId=<connected-serverId>.
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}" "${FORM_URL}"
wait "id=\"combined-model-selector\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"agent-controls-model\"" 10000
gesture pan 540 790 0 960 500
wait "id=\"combined-model-selector\"" 3000
# Reopen, dismiss the nested model browser, then dismiss its restored parent.
press "id=\"combined-model-selector\""
wait "id=\"agent-controls-model\"" 10000
press "id=\"agent-controls-model\""
wait stable 300 5000
gesture pan 540 790 0 960 500
wait "id=\"agent-controls-model\"" 3000
gesture pan 540 790 0 960 500
wait "id=\"combined-model-selector\"" 3000
close
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
// Open an idle terminal with an empty prompt and hide its keyboard before running.
// The app must already run the checkout/build being verified against a real daemon.
const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug";
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android");
const session = "terminal-keyboard-android";
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 });
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function keyboardState() {
const state = adb("shell", "dumpsys", "input_method");
const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? "";
return {
shown: /^ mInputShown=true$/m.test(state),
// The native object identity survives layout/flag changes but not a remount.
input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null,
};
}
async function waitForKeyboard(shown) {
let stable = 0;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const state = keyboardState();
stable = state.shown === shown ? stable + 1 : 0;
if (stable === 3) return state;
await setTimeout(100);
}
throw new Error(`Keyboard did not settle at shown=${shown}`);
}
function keyboardShift() {
const state = adb("shell", "dumpsys", "window");
const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/);
const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/);
assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar");
return Number(navigation[1]) - Number(ime[1]);
}
function imeEvents() {
return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S")
.split("\n")
.filter((line) => line.includes(`${appId}:`));
}
function tap(rect, shift) {
ad(
"press",
String(Math.round(rect.x + rect.width / 2)),
String(Math.round(rect.y + rect.height / 2 - shift)),
);
}
let toggle;
try {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden");
// Accessibility snapshots can hang with a real IME open. Measure the controls
// while hidden, then project them by Android's actual docked-keyboard inset.
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const rectFor = (id) => {
const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id);
assert.ok(node?.rect, `Open a terminal first: missing ${id}`);
return node.rect;
};
toggle = rectFor("terminal-keyboard-toggle");
const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({
key,
rect: rectFor(`terminal-key-${key}`),
}));
tap(toggle, 0);
const baseline = await waitForKeyboard(true);
assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME");
const shift = keyboardShift();
const before = new Set(imeEvents());
ad("screenshot", resolve(artifacts, "before.png"));
for (const { key, rect } of controls) {
tap(rect, shift);
const after = await waitForKeyboard(true);
const events = imeEvents().filter((line) => !before.has(line));
writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n"));
writeFileSync(
resolve(artifacts, "focus.json"),
JSON.stringify({ key, baseline, after }, null, 2),
);
assert.equal(after.input, baseline.input, `${key} replaced the focused native input`);
assert.deepEqual(
events.filter((line) => /onRequestHide|onRequestShow/.test(line)),
[],
`${key} cycled the software keyboard`,
);
}
ad("screenshot", resolve(artifacts, "after.png"));
console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests");
} finally {
if (toggle && keyboardState().shown) {
tap(toggle, keyboardShift());
await waitForKeyboard(false);
}
ad("close");
run("agent-device", ["daemon", "stop", "--clean"]);
}
@@ -0,0 +1,29 @@
import { expect, type Locator, type Page } from "@playwright/test";
const CHANGELOG_SOURCE_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
/**
* Serves a changelog to the app instead of the repository's.
*
* The sheet renders whatever the document contains, so a fixture is how a test
* states which authoring shapes it cares about.
*/
export async function serveChangelog(page: Page, lines: string[]): Promise<void> {
await page.route(CHANGELOG_SOURCE_URL, (route) =>
route.fulfill({ status: 200, contentType: "text/plain", body: lines.join("\n") }),
);
}
export async function openWhatsNew(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-help").click();
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
await page.getByTestId("sidebar-help-changelog").click();
const sheet = page.getByTestId("changelog-sheet");
await expect(sheet).toBeVisible();
return sheet;
}
export function release(sheet: Locator, version: string): Locator {
return sheet.getByTestId(`changelog-release-${version}`);
}
@@ -0,0 +1,201 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { gotoWorkspace } from "./launcher";
import { buildAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import { ensureExplorerSidebar } from "./workspace-tabs";
type ExplorerPanelWorkspace = Awaited<ReturnType<typeof seedWorkspace>>;
async function writePanelPlugin(id: string, title: string) {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`
import React from "react";
import { Text } from "react-native";
export default function contribute(client) {
client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => <Text>{${JSON.stringify(title)} + " workspace " + workspaceId}</Text> });
client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => <Text>Summary content</Text> });
client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null });
client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null });
return () => {};
}`,
);
return directory;
}
async function openExplorerMenu(page: Page) {
const explorer = await ensureExplorerSidebar(page);
// The rail's empty background has no accessible role; tab menus are separate.
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
button: "right",
position: { x: 20, y: 2 },
});
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
await expect(menu).toBeVisible();
await expect(menu).toHaveCSS("opacity", "1");
return menu;
}
async function toggleExplorerView(page: Page, name: string) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name, exact: true }).click();
await expect(menu).not.toBeVisible();
}
export async function withExplorerPanelPlugins(
page: Page,
run: (workspace: ExplorerPanelWorkspace) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" });
const first = await writePanelPlugin("explorer-review", "Review");
const second = await writePanelPlugin("explorer-other", "Other review");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(first);
await client.installDirectoryPlugin(second);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(workspace);
} finally {
await client.removePlugin("explorer-review");
await client.removePlugin("explorer-other");
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(first, { recursive: true, force: true });
await rm(second, { recursive: true, force: true });
}
}
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
const launcher = page.getByTestId("workspace-explorer-sidebar");
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
0,
);
}
export async function openWorkspacePanelFromExplorerMenu(
page: Page,
workspace: ExplorerPanelWorkspace,
testInfo: TestInfo,
) {
const menu = await openExplorerMenu(page);
await testInfo.attach("explorer-panel-menu", {
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
contentType: "image/png",
});
await expect(menu.getByRole("menuitem")).toHaveText([
"New tab",
"Changes",
"Files",
"Other review",
"Other review summary",
"Review",
"Review summary",
]);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
await expect(
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
}
export async function expectIndependentExplorerPanelToggles(
page: Page,
workspace: ExplorerPanelWorkspace,
) {
await toggleExplorerView(page, "Other review");
await expect(
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await toggleExplorerView(page, "Review summary");
const menu = await openExplorerMenu(page);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
const updated = await openExplorerMenu(page);
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await expect(
updated.getByRole("menuitem", { name: "Other review", exact: true }),
).toHaveAttribute("aria-checked", "true");
await expect(
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
).toHaveAttribute("aria-checked", "true");
await page.keyboard.press("Escape");
await toggleExplorerView(page, "Files");
await toggleExplorerView(page, "Files");
await expect(
page
.getByTestId("explorer-sidebar-tab-rail")
.getByRole("button", { name: "Browse workspace files", exact: true }),
).toHaveCount(1);
}
export async function expectExplorerPanelWithFocusedAgent(
page: Page,
workspace: ExplorerPanelWorkspace,
testInfo: TestInfo,
) {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Focused agent",
model: "ten-second-stream",
modeId: "load-test",
});
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
const menu = await openExplorerMenu(page);
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount(
0,
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
await expect(
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await testInfo.attach("workspace-panel-with-focused-agent", {
body: await page.screenshot({
path: testInfo.outputPath("workspace-panel-with-focused-agent.png"),
}),
contentType: "image/png",
});
}
@@ -0,0 +1,71 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { expect, type Page } from "../fixtures";
import { drillIntoProvider, openModelPicker } from "./agent-profiles";
export async function startWithoutRememberedModel(page: Page) {
await page.addInitScript(() => localStorage.removeItem("@paseo:create-agent-preferences"));
}
export async function chooseModel(page: Page, provider: string, label: string) {
await openModelPicker(page);
await expect(
page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }),
).toHaveCount(0);
await selectProviderModel(page, provider, label);
}
export async function reselectModel(page: Page, provider: string, label: string) {
await expectRememberedModel(page, label);
await openModelPicker(page);
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
await selectProviderModel(page, provider, label);
}
async function selectProviderModel(page: Page, provider: string, label: string) {
await drillIntoProvider(page, provider);
await page.getByTestId("combobox-desktop-container").getByText(label, { exact: true }).click();
await expect(
page
.getByRole("button", { name: `Select model (${label})`, exact: true })
.filter({ visible: true }),
).toBeVisible();
}
export async function expectSavedSelection(page: Page, provider: string, model: string) {
await expect
.poll(() =>
page.evaluate(() =>
JSON.parse(localStorage.getItem("@paseo:create-agent-preferences") ?? "null"),
),
)
.toMatchObject({ provider, providerPreferences: { [provider]: { model } } });
}
export async function expectCreatedModelAgents(
page: Page,
client: DaemonClient,
provider: string,
model: string,
count: number,
) {
await expect
.poll(
async () => {
const result = await client.fetchAgents({ scope: "active" });
return result.entries
.filter(({ agent }) => agent.provider === provider)
.map(({ agent }) => agent.model);
},
{ timeout: 60_000 },
)
.toEqual(Array(count).fill(model));
await expect(page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true })).toHaveCount(1);
}
export async function expectRememberedModel(page: Page, label: string) {
await expect(
page
.getByRole("button", { name: `Select model (${label})`, exact: true })
.filter({ visible: true }),
).toBeVisible();
}
@@ -0,0 +1,197 @@
import { expect, test, type Page } from "../fixtures";
import { readFile } from "node:fs/promises";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
countWorkspaceAgents,
countWorkspaceTerminals,
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
openProjectViaDaemon,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
waitForCreatedWorkspace,
} from "./new-workspace";
import { createTempGitRepo } from "./workspace";
import { expectAppRoute } from "./route-assertions";
import { getServerId } from "./server-id";
import {
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
} from "./new-workspace-launch";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
const PROMPT = "Hello from the navigation guard";
const NEXT_PROMPT = "Keep this newer draft";
const CONTEXT = {
name: "context.json",
mimeType: "application/json",
buffer: Buffer.from('{"preserve":"attachment"}'),
};
const PROFILE: TerminalProfile = {
id: "e2e-nav-guard-echo",
name: "Nav Guard Echo",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
};
export async function verifyDelayedWorkspaceCreation(
page: Page,
launch: "chat" | "terminal" | "empty",
destination: "leave" | "stay" | "new-draft",
isolation: "local" | "worktree" = "local",
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("workspace-focus-");
const profileSeed = await seedTerminalProfiles([PROFILE]);
const delay = await delayBrowserWorkspaceCreatedResponse(page);
const serverId = getServerId();
let localWorkspaceId: string | undefined;
let createdDirectory: string | undefined;
let newDraftRoute = "";
try {
const project = await openProjectViaDaemon(client, repo.path);
localWorkspaceId = project.workspaceId;
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await test.step("Submit workspace creation with the requested content", async () => {
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
if (launch === "chat") {
await dropFileOnComposer(page, CONTEXT);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await submitNewWorkspacePrompt(page, PROMPT);
} else if (launch === "terminal") {
await selectLaunchOption(page, PROFILE.id);
await fillTerminalPrompt(page, PROMPT);
await submitTerminalLaunch(page);
} else {
await submitNewWorkspaceEmpty(page);
}
await delay.waitForCreateRequest();
});
await test.step("Choose where to work while the creation response is held", async () => {
if (destination !== "stay") {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
}
if (destination === "new-draft") {
await openNewWorkspaceComposer(page, project);
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
const url = new URL(page.url());
newDraftRoute = url.pathname + url.search;
}
// Recording pacing only; the response is held deterministically above.
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
delay.release();
});
const created = await waitForCreatedWorkspace(client, knownIds);
createdDirectory = created.workspaceDirectory;
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
// A negative navigation assertion needs an observation window after completion.
await page.waitForTimeout(5000);
const expectedRoute =
destination === "new-draft"
? newDraftRoute
: buildHostWorkspaceRoute(
serverId,
destination === "stay" ? created.id : project.workspaceId,
);
await expectAppRoute(page, expectedRoute);
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
const upload = delay.agentRequests[0]?.attachments?.find(
(item) => item.type === "uploaded_file",
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
expect(delay.agentRequests[0]).toMatchObject({
workspaceId: created.id,
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: created.workspaceDirectory,
},
attachments: [
expect.objectContaining({
fileName: CONTEXT.name,
mimeType: CONTEXT.mimeType,
size: CONTEXT.buffer.length,
}),
],
});
}
});
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
if (launch === "chat") {
await scrollTimelineToOldestLoadedEdge(page);
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
} else if (launch === "terminal") {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
}
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
await openNewWorkspaceComposer(page, project);
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
}
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
});
} catch (error) {
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
console.error(error);
throw error;
} finally {
delay.release();
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
await profileSeed.restore();
await client.close();
await repo.cleanup();
}
}
@@ -1,4 +1,5 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -16,12 +17,16 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "installDirectoryPlugin"
| "disablePlugin"
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -620,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly CreateAgentRequestMessage[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: CreateAgentRequestMessage[] = [];
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request")
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
if (sessionMessage?.type === "workspace.create.request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "workspace.create.response" &&
typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
agentRequests,
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
};
}
type WorkspaceEntry = Awaited<
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
>["entries"][number];
/**
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
* signal that creation finished.
*/
export async function waitForCreatedWorkspace(
client: NewWorkspaceDaemonClient,
knownWorkspaceIds: ReadonlySet<string>,
options?: { timeout?: number },
): Promise<WorkspaceEntry> {
let created: WorkspaceEntry | undefined;
await expect
.poll(
async () => {
const payload = await client.fetchWorkspaces();
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
return created !== undefined;
},
{ timeout: options?.timeout ?? 60_000 },
)
.toBe(true);
if (!created) {
throw new Error("Workspace list reported a new workspace but it could not be read back");
}
return created;
}
export async function countWorkspaceAgents(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
}
export async function countWorkspaceTerminals(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
return payload.terminals.length;
}
@@ -0,0 +1,195 @@
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { waitForSettledPosition } from "./sheet-layout";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
function headerButtons(page: Page) {
return page.getByRole("toolbar", { name: "Workspace actions", exact: true }).getByRole("button");
}
async function exampleCommand(page: Page, name: string) {
await page.setViewportSize(WIDE);
const panel = await openCommandCenter(page);
const title = `Button examples: ${name}`;
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(
page: Page,
info: TestInfo,
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await info.attach(name, { path: file, contentType: "image/png" });
}
async function closeDetails(page: Page) {
await page.getByRole("button", { name: "Close workspace details", exact: true }).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toHaveCount(0);
}
export async function withButtonAuthorExample(
page: Page,
info: TestInfo,
run: (buttons: {
refreshFromHeaderAndComposer(): Promise<void>;
useMenusAndToggleButtons(): Promise<void>;
inspectLiveWorkspaceDetails(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "button-example-",
title: "Button examples",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Example agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const example = await copyPluginExample("buttons");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await run({
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
await expect(
page.getByRole("button", { name: "Refresh context", exact: true }),
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await capture(page, info, "02-compact-actions");
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
await exampleCommand(page, "menu");
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Publish (unavailable in example)", exact: true }),
).toBeDisabled();
await capture(
page,
info,
"03-header-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Workspace details", exact: true }).click();
await expect(page.getByText("Button examples", { exact: true }).last()).toBeVisible();
await closeDetails(page);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await capture(
page,
info,
"04-composer-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await page.getByRole("menuitem", { name: "Hide example buttons", exact: true }).click();
await expect(headerButtons(page)).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Composer tools", exact: true }),
).toHaveCount(0);
await exampleCommand(page, "show");
await exampleCommand(page, "disable");
await expect(headerButtons(page)).toBeDisabled();
await expect(
page.getByRole("button", { name: "Composer tools", exact: true }),
).toBeDisabled();
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
await capture(
page,
info,
"06-compact-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Refresh workspace", exact: true }).click();
}),
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toBeVisible();
await capture(
page,
info,
"07-header-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await client.setWorkspaceTitle(workspace.workspaceId, "Renamed from the daemon");
await expect(
page.getByText("Renamed from the daemon", { exact: true }).last(),
).toBeVisible();
await closeDetails(page);
await page.getByRole("button", { name: "Context details", exact: true }).click();
await capture(
page,
info,
"08-composer-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await closeDetails(page);
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Context details", exact: true }).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toBeInViewport();
await capture(
page,
info,
"09-compact-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await closeDetails(page);
await client.disablePlugin("button-examples");
await expect(headerButtons(page)).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Context details", exact: true }),
).toHaveCount(0);
}),
});
} finally {
await client.removePlugin("button-examples");
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,486 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
const PLUGIN_ID = "button-showcase";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
function clientSource(workspaceId: string, agentId: string): string {
return `import React from "react";
import { Text, View, Pressable } from "react-native";
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
import { useQuery } from "@tanstack/react-query";
import { summary } from "./shared/rpc";
function StatusIcon({ size, theme }) {
return <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: theme.colors.statusSuccess }} />;
}
function Details({ workspaceId, theme, layout, close }) {
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
const rpc = useRpc(summary);
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
return <View style={{ gap: 12 }}>
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>3 checks passed · Production</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Close deployment details" onPress={close} style={{ padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 }}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>;
}
export default function contribute(client) {
const workspaceId = ${JSON.stringify(workspaceId)};
const agentId = ${JSON.stringify(agentId)};
const registrations = [];
let finish = () => {};
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
const header = (id, button) => { const registration = client.addHeaderButton({ id, workspaceId, button }); registrations.push(registration); return registration; };
const pill = (id, button) => { const registration = client.addComposerPill({ id, workspaceId, agentId, button }); registrations.push(registration); return registration; };
const menu = { kind: "menu", items: [
{ kind: "item", id: "run", title: "Run checks", icon: "Play", behavior: { kind: "action", onPress() { checks.update({ label: "Checks passed" }); } } },
{ kind: "separator", id: "divider" },
{ kind: "item", id: "environment", title: "Environment", behavior: { kind: "menu", items: [
{ kind: "item", id: "staging", title: "Staging", icon: "FlaskConical", behavior: { kind: "action", onPress() {} } },
{ kind: "item", id: "production", title: "Production", icon: "Globe", disabled: true, behavior: { kind: "action", onPress() {} } },
] } },
{ kind: "item", id: "details", title: "Deployment details", icon: "Activity", behavior: { kind: "popover", Content: Details } },
] };
const deploy = header("deploy", { title: "Deploy application", icon: "Rocket", label: "Deploy", behavior: action });
const checks = header("checks", { title: "Header checks", icon: "ListChecks", label: "Checks", behavior: menu });
header("status", { title: "Header status", icon: StatusIcon, label: "Healthy", behavior: { kind: "popover", Content: Details } });
header("logs", { title: "Open deployment logs", icon: "ScrollText", behavior: { kind: "popover", Content: Details } });
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
command("hide-deploy", "Hide deployment button", () => deploy.update({ visible: false }));
command("show-deploy", "Show deployment button", () => deploy.update({ visible: true }));
command("hide", "Hide review button", () => review.update({ visible: false }));
command("show", "Show review button", () => review.update({ visible: true, label: "Review ready" }));
command("disable", "Disable example buttons", () => { deploy.update({ disabled: true }); review.update({ disabled: true }); });
command("enable", "Enable example buttons", () => { deploy.update({ disabled: false }); review.update({ disabled: false }); });
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
if (update.kind !== "upsert") return;
deploy.update({ visible: update.workspace.name !== "Compact checks" });
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
});
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
}`;
}
async function installShowcase(workspaceId: string, agentId: string) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-buttons-"));
await mkdir(path.join(directory, "shared"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), clientSource(workspaceId, agentId));
await writeFile(
path.join(directory, "shared/rpc.ts"),
`import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const summary = defineRpc({ name: "summary", input: z.object({}), output: z.object({ message: z.string() }) });`,
);
await writeFile(
path.join(directory, "index.server.ts"),
`import { summary } from "./shared/rpc"; export default function contribute(server) { server.handle(summary, async () => ({ message: "All systems operational" })); return () => {}; }`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
return {
disable: () => client.disablePlugin(PLUGIN_ID),
setTitle: (title: string) => client.setWorkspaceTitle(workspaceId, title),
async cleanup() {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
},
};
}
async function press(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function openHeaderOverflow(page: Page) {
await page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true })
.click();
}
async function choose(page: Page, title: string) {
await page.getByRole("menuitem", { name: title, exact: true }).click();
}
async function command(page: Page, title: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(
page: Page,
testInfo: TestInfo,
name: string,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
) {
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
await expect(title).toHaveCSS("text-overflow", "ellipsis");
await expect(subtitle).toHaveCSS("text-overflow", "ellipsis");
// The text itself must overflow its box, rather than being clipped by an ancestor.
await expect
.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true);
await expect
.poll(() => subtitle.evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true);
const titleBox = await title.boundingBox();
const subtitleBox = await subtitle.boundingBox();
const actionsBox = await page
.getByRole("button", { name: "Workspace actions", exact: true })
.boundingBox();
expect(titleBox).not.toBeNull();
expect(subtitleBox).not.toBeNull();
expect(actionsBox).not.toBeNull();
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
expect(subtitleBox!.x + subtitleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
const hostIcon = page.getByLabel("localhost", { exact: true }).locator("svg");
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await capture(page, testInfo, "12c-compact-overflow-pressed");
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
testInfo: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
openCompactSheets(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Button showcase",
model: "ten-second-stream",
modeId: "load-test",
});
const showcase = await installShowcase(workspace.workspaceId, agent.id);
try {
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
await run({
openWideMenusAndPopovers: () =>
test.step("wide buttons have placement-owned labels and chevrons", async () => {
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(1);
await expect(
page
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(page, testInfo, "01-wide-buttons");
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
).toBeVisible();
await capture(
page,
testInfo,
"02-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Environment");
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
await capture(
page,
testInfo,
"03-header-submenu",
page.getByRole("menuitem", { name: "Staging", exact: true }),
);
await choose(page, "Staging");
await openHeaderOverflow(page);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
page,
testInfo,
"03b-wide-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Open deployment logs");
await expectDetails(page);
await press(page, "Close deployment details");
await press(page, "Header status");
await expectDetails(page);
await capture(
page,
testInfo,
"04-header-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
page,
testInfo,
"05-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
page,
testInfo,
"06-composer-menu-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer status");
await expectDetails(page);
await capture(
page,
testInfo,
"07-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
}),
runAndUpdateActions: () =>
test.step("actions fail visibly, retry, and remain busy until completion", async () => {
await press(page, "Deploy application");
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await capture(
page,
testInfo,
"08-action-error",
page.getByText("Deployment failed. Try again.", { exact: true }),
);
await press(page, "Deploy application");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09-action-pending");
await command(page, "Finish deployment");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toContainText("Deployed");
await press(page, "Run review");
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09b-composer-pending");
await command(page, "Finish review");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
"Reviewed",
);
await command(page, "Disable example buttons");
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "10-disabled-buttons");
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await capture(page, testInfo, "11-icon-only-header");
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
);
await command(page, "Show review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
"Review ready",
);
}),
openCompactSheets: () =>
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toContainText("Checks");
await workspace.client.renameProject(
workspace.projectId,
"Plugin buttons with a long project name",
);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(
"Plugin buttons with a long project name",
);
await showcase.setTitle("Button showcase with a long workspace title");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await capture(page, testInfo, "12-compact-buttons");
await openCompactOverflowWithHeaderChrome(page, testInfo);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
page,
testInfo,
"13-compact-header-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await capture(
page,
testInfo,
"14-compact-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
page,
testInfo,
"15-compact-header-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(
page,
testInfo,
"15b-compact-header-direct-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
await capture(
page,
testInfo,
"16-compact-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
page,
testInfo,
"17-compact-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
}),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
await press(page, "Composer checks");
await showcase.setTitle("Hide composer menu");
await expect(page.getByRole("menuitem", { name: "Run checks", exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toHaveCount(0);
await showcase.setTitle("Button showcase");
await page.emulateMedia({ colorScheme: "dark" });
await capture(page, testInfo, "18-dark-buttons");
await press(page, "Header status");
await expectDetails(page);
await capture(
page,
testInfo,
"19-dark-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await showcase.disable();
await expect(
page.getByRole("button", { name: "Header status", exact: true }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
}),
});
} finally {
await showcase.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,29 @@
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { z } from "zod";
import appPackage from "../../../package.json";
export const pluginRequirements = { paseo: `>=${appPackage.version}` };
export async function copyPluginExample(name: string) {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-"));
try {
await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, {
recursive: true,
});
const manifestPath = path.join(directory, "paseo-plugin.json");
const manifest = z
.record(z.string(), z.unknown())
.parse(JSON.parse(await readFile(manifestPath, "utf8")));
// Exercise example UI against the checkout runtime before its release version is bumped.
await writeFile(
manifestPath,
JSON.stringify({ ...manifest, requirements: pluginRequirements }),
);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
} catch (error) {
await rm(directory, { recursive: true, force: true });
throw error;
}
}
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { openModelPicker } from "./agent-profiles";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
import { getServerId } from "./server-id";
import { openSettingsHostSection } from "./settings";
const MODEL_LABEL = "Select model (Example 1)";
const WIDE = { width: 1400, height: 950 };
const COMPACT = { width: 390, height: 844 };
async function readIconPaths(page: Page, pluginDirectory: string): Promise<string[]> {
const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8");
return page.evaluate(
(source) =>
Array.from(
new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"),
(element) => element.getAttribute("d") ?? "",
),
svg,
);
}
function readIconDrawings(icons: SVGElement[]): string[][] {
return icons.map((icon) =>
Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""),
);
}
async function expectProviderIcon(surface: Locator, paths: string[]): Promise<void> {
await expect(surface).toBeVisible();
// Provider icons are decorative SVGs without accessible names. Compare the
// plugin asset's drawing, allowing surface-specific size and theme colours.
await expect
.poll(() => surface.locator("svg").evaluateAll(readIconDrawings))
.toContainEqual(paths);
}
async function selectPluginModel(page: Page): Promise<void> {
await openModelPicker(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByText("Direct provider example", { exact: true }).click();
await page.getByText("Example 1", { exact: true }).click();
await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
interface ProviderIconJourney {
page: Page;
testInfo: TestInfo;
iconPaths: string[];
workspace: SeededWorkspace;
}
export const test = base.extend<{ providerIcons: ProviderIconJourney }>({
providerIcons: async ({ page }, provide, testInfo) => {
const client = await connectNewWorkspaceDaemonClient();
try {
const previous = await client.getDaemonConfig();
const plugin = await copyPluginExample("provider-direct");
try {
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" });
try {
await page.setViewportSize(WIDE);
await gotoAppShell(page);
const iconPaths = await readIconPaths(page, plugin.directory);
await provide({ page, testInfo, iconPaths, workspace });
} finally {
await workspace.cleanup();
}
} finally {
try {
await client.removePlugin("provider-direct-example");
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
}
}
} finally {
await plugin.cleanup();
}
} finally {
await client.close();
}
},
});
export async function verifyProviderSettings({
page,
iconPaths,
testInfo,
}: ProviderIconJourney): Promise<void> {
await test.step("provider settings render the registered icon", async () => {
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expectProviderIcon(
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
iconPaths,
);
await capture(page, testInfo, "provider-settings");
await page.getByTestId("settings-back-to-workspace").click();
});
}
export async function verifyNewWorkspaceModelIcon({
page,
iconPaths,
testInfo,
}: ProviderIconJourney): Promise<void> {
await test.step("new workspace keeps the picker icon on its selected model button", async () => {
await openGlobalNewWorkspaceComposer(page);
await selectPluginModel(page);
await openModelPicker(page);
await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths);
await capture(page, testInfo, "new-workspace-picker");
await page.keyboard.press("Escape");
await expectProviderIcon(
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
iconPaths,
);
await capture(page, testInfo, "new-workspace-selected-model");
});
}
export async function verifyCompactModelIcon({
page,
iconPaths,
testInfo,
}: ProviderIconJourney): Promise<void> {
await test.step("compact composer preserves the same provider icon", async () => {
await page.setViewportSize(COMPACT);
await expectProviderIcon(
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
iconPaths,
);
await capture(page, testInfo, "compact-selected-model");
});
}
export async function verifyExistingAgentModelIcon({
page,
iconPaths,
testInfo,
workspace,
}: ProviderIconJourney): Promise<void> {
await test.step("existing agent composer uses the same icon", async () => {
const agent = await workspace.client.createAgent({
provider: "direct-example",
model: "example-1",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Plugin icon agent",
});
await page.setViewportSize(WIDE);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expectProviderIcon(
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
iconPaths,
);
await capture(page, testInfo, "agent-selected-model");
});
}
@@ -0,0 +1,114 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { spawnTsx, killProcessTree } from "./spawn-node";
import { expect, test as base, type Page } from "../fixtures";
import { connectDaemonClient } from "./daemon-client-loader";
import { addConnectedHostAndReload } from "./hosts";
import { gotoAppShell, openSettings } from "./app";
import { openHostSection, selectSettingsHost } from "./settings";
import { pluginRequirements } from "./plugin-fixture";
export const test = base.extend<{
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
}>({
requirementHost: async ({ e2eWorker }, provide) => {
void e2eWorker;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-"));
await writeRequirements(directory, "^99.0.0");
await writeFile(
path.join(directory, "index.client.ts"),
`export default function(client) {
return () => {};
}`,
);
const daemon = spawnTsx(
path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"),
["99.0.0"],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const timeout = setTimeout(
() => ready.reject(new Error("Versioned daemon startup timed out")),
30_000,
);
let output = "";
daemon.stdout?.on("data", (data) => {
output += data;
});
daemon.stderr?.on("data", (data) => {
output += data;
});
daemon.once("error", ready.reject);
daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`)));
daemon.once("message", (message) => {
if (
typeof message !== "object" ||
message === null ||
!("port" in message) ||
typeof message.port !== "number" ||
!("serverId" in message) ||
typeof message.serverId !== "string"
) {
ready.reject(new Error("Invalid versioned daemon startup message"));
return;
}
ready.resolve({ port: message.port, serverId: message.serverId });
});
try {
const host = await ready.promise;
clearTimeout(timeout);
const client = await connectDaemonClient<DaemonClient>({
port: host.port,
clientIdPrefix: "requirements",
});
try {
await client.installDirectoryPlugin(directory);
await provide({ ...host, directory, client });
} finally {
await client.close();
}
} finally {
clearTimeout(timeout);
await killProcessTree(daemon);
await rm(directory, { recursive: true, force: true });
}
},
});
async function writeRequirements(directory: string, paseo: string) {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
);
}
export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) {
await gotoAppShell(page);
await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" });
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "plugins");
}
export async function expectAppMismatch(page: Page) {
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
await expect(
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
).toBeVisible();
}
export async function correctRequirementAndReload(page: Page, directory: string) {
await writeRequirements(directory, pluginRequirements.paseo);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
await expect(page.getByText(/Your app is/)).toHaveCount(0);
}
export async function rejectDaemonMismatch(page: Page, directory: string) {
await writeRequirements(directory, ">=100.0.0");
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
}
@@ -0,0 +1,109 @@
import { copyPluginExample } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
void e2eWorker;
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
}
},
{ auto: true },
],
});
export async function openDisplaySettings(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openPluginScreen(page);
}
export async function openCompactDisplaySettings(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page, new URL(page.url()).pathname);
await openPluginScreen(page);
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
}
export async function hideMetadata(page: Page) {
await page.getByRole("switch", { name: "Show metadata" }).click();
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
}
export async function reloadSavedDisplaySettings(page: Page) {
await page.reload();
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked();
}
export async function editTitle(page: Page, title: string) {
await page.getByRole("button", { name: "Edit title", exact: true }).click();
await replaceTitle(page, title);
}
export async function replaceTitle(page: Page, title: string) {
await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title);
}
export async function saveTitle(page: Page) {
await page.getByRole("button", { name: "Save title", exact: true }).click();
}
export async function expectTitleRequired(page: Page) {
await expect(page.getByText(/Enter a title/).first()).toBeVisible();
}
export async function expectSavedTitle(page: Page, title: string) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0);
}
export async function changeMetadataFromAnotherClient(page: Page) {
const peer = await page.context().newPage();
try {
await peer.goto(page.url());
await hideMetadata(peer);
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
} finally {
await peer.close();
}
}
export async function expectConflictPreservesDraft(page: Page, title: string) {
await expect(page.getByText(/Settings changed on another client/)).toBeVisible();
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue(
title,
);
}
export async function discardTitle(page: Page) {
await page.getByRole("button", { name: "Discard", exact: true }).click();
await expectSavedTitle(page, "Agent monitor");
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
).toBeVisible();
}
@@ -0,0 +1,94 @@
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
import { launchAgent, sendMessage, type AgentHandle } from "./rewind-flow";
import { openSubagentsTrack } from "./subagents";
const NESTED_OWNERSHIP_PROMPT =
"You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. " +
"Name the agent direct_owner and give it this complete task: You are DIRECT_OWNER. Use " +
"Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it " +
"this complete task: You are NESTED_OWNER. Use Bash exactly once to run `sleep 2; printf " +
"'NESTED_BACKGROUND_SENTINEL\\n'` with run_in_background true. Wait for the background " +
"command's completion notification. Use TaskOutput with block true to wait for the Bash task and verify exit code 0 and the sentinel output before replying exactly NESTED_DONE. Do not finish while it is running. Wait for nested_owner " +
"to finish, then reply exactly DIRECT_DONE. Wait for direct_owner to finish, then reply " +
"exactly ROOT_DONE.";
export async function launchNestedProviderSubagentOwnershipScenario(
page: Page,
cwd: string,
): Promise<AgentHandle> {
const handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "claude-sonnet-5" },
});
await sendMessage(handle, NESTED_OWNERSHIP_PROMPT);
await expect(page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible({
timeout: 120_000,
});
return handle;
}
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
async function expectOnlySubagent(page: Page, label: RegExp): Promise<Locator> {
const rows = page.locator('[data-testid^="subagents-track-row-"]');
await expect(rows).toHaveCount(1);
const row = page.getByTestId("subagents-track-header-panel").getByRole("button", { name: label });
await expect(row).toBeVisible();
return row;
}
async function selectOnlyRootSubagent(page: Page, label: RegExp): Promise<void> {
await openSubagentsTrack(page);
const row = await expectOnlySubagent(page, label);
await row.click();
}
async function openOwnedSubagents(page: Page, ownerPanel: Locator): Promise<void> {
await ownerPanel.getByTestId("subagents-track-header").click();
await expect(page.getByTestId("subagents-track-header-panel")).toBeVisible();
}
export async function expectNestedProviderSubagentOwnership(
page: Page,
testInfo: TestInfo,
phase: string,
): Promise<void> {
await expect(
page.getByText("Task notification", { exact: true }).filter({ visible: true }),
).toHaveCount(0);
await selectOnlyRootSubagent(page, /^direct_owner\b/);
const directPanel = page.getByTestId("provider-subagent-panel").filter({ visible: true });
await expect(directPanel).toBeVisible();
await openOwnedSubagents(page, directPanel);
const nestedRow = await expectOnlySubagent(page, /^nested_owner\b/);
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-tree`);
await nestedRow.click();
const nestedPanel = page.locator('[data-testid="provider-subagent-panel"]:visible');
await expect(
nestedPanel
.getByRole("button", {
name: /^Task notification Background command .*completed \(exit code 0\)/,
})
.first(),
).toBeVisible();
await expect(nestedPanel).toContainText("NESTED_BACKGROUND_SENTINEL");
await expect(nestedPanel).toContainText("NESTED_DONE");
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-timeline`);
}
export async function reopenNestedProviderSession(handle: AgentHandle): Promise<void> {
await handle.client.refreshAgent(handle.agentId);
await handle.page.reload();
await handle.page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
await expect(handle.page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible();
}
@@ -318,9 +318,9 @@ export async function assertFileContains(filePath: string, text: string): Promis
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(
handle.page.getByRole("button", { name: /^(?:Stop agent|Canceling agent)$/ }),
).toHaveCount(0, { timeout: 30_000 });
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
@@ -45,6 +45,7 @@ export interface SeedDaemonClient {
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
clearWorkspaceAttention(workspaceId: string): Promise<void>;
setWorkspaceLabel(input: {
workspaceId: string;
label: { name: string; color: "red" };
@@ -159,6 +160,7 @@ export interface SeedDaemonClient {
features?: {
projectAdd?: boolean;
workspaceRecovery?: boolean;
workspaceMarkUnread?: boolean;
} | null;
} | null;
fetchAgentHistory(options?: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.2",
"version": "0.8.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
+1 -1
View File
@@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -6,7 +6,7 @@ import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -9,7 +9,6 @@ import {
} from "react";
import {
FlatList,
Keyboard,
Platform,
View,
type LayoutChangeEvent,
@@ -23,6 +22,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
@@ -104,6 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const settledKeyboardShift = useSettledKeyboardShift();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
@@ -273,6 +275,34 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const streamKeyboardInset = useMemo(
() =>
resolveStreamKeyboardInset({
platform: Platform.OS === "ios" ? "ios" : "android",
settledShift: settledKeyboardShift,
}),
[settledKeyboardShift],
);
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
streamKeyboardInset.contentInset
? {
automaticallyAdjustContentInsets: false,
automaticallyAdjustsScrollIndicatorInsets: false,
contentInsetAdjustmentBehavior: "never" as const,
contentInset: streamKeyboardInset.contentInset,
scrollIndicatorInsets: streamKeyboardInset.contentInset,
}
: {},
[streamKeyboardInset.contentInset],
);
useEffect(() => {
streamViewportMetricsRef.current = {
@@ -305,27 +335,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => {
const keyboardEvents = [
"keyboardWillShow",
"keyboardWillHide",
"keyboardDidShow",
"keyboardDidHide",
"keyboardWillChangeFrame",
"keyboardDidChangeFrame",
] as const;
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
}),
);
return () => {
for (const subscription of subscriptions) {
subscription.remove();
}
clearNativeViewportSettling();
};
}, [clearNativeViewportSettling, markNativeViewportSettling]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
@@ -560,6 +570,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// data or the live header changes, preserving the row identities above.
return (
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
@@ -569,7 +580,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
contentContainerStyle={listContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
+3 -10
View File
@@ -32,7 +32,7 @@ import {
AssistantMessage,
SpeakMessage,
UserMessage,
ActivityLog,
Notification,
ToolCall,
TodoListCard,
CompactionMarker,
@@ -877,15 +877,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "tool_call":
return renderToolCallItem(layoutItem, item);
case "activity_log":
return (
<ActivityLog
type={item.activityType}
message={item.message}
timestamp={item.timestamp.getTime()}
metadata={item.metadata}
/>
);
case "notification":
return <Notification level={item.level} message={item.message} />;
case "todo_list":
return <TodoListCard items={item.items} activity={item.activity} />;
@@ -70,7 +70,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
case "activity_log":
case "notification":
return 88;
case "compaction":
return 72;
+60 -37
View File
@@ -28,12 +28,13 @@ import { CommandCenterProvider } from "@/command-center/provider";
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
import { PluginCommandCenterActions } from "@/plugins/command-center/registration";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
@@ -69,6 +70,7 @@ import {
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
bindHostRuntimeAppState,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
@@ -372,6 +374,11 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
return bindHostRuntimeAppState(store, AppState);
}, []);
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
@@ -545,11 +552,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
/>
);
let themedSidebarChrome = sidebarChrome;
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{sidebarChrome}
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
{usesCompactExplorerHost ? (
@@ -569,42 +580,49 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</View>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
// Their tracked styles update in place; web numeric styles still need remounting.
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
<WindowChromeSafeArea
placement="inline"
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
pointerEvents="box-none"
style={layoutStyles.windowSidebarToggle}
>
<WindowSidebarMenuToggle />
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<AppDiagnosticHost />
<QuittingOverlay />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
<WindowChromeSafeArea
placement="inline"
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
pointerEvents="box-none"
style={layoutStyles.windowSidebarToggle}
>
<WindowSidebarMenuToggle />
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
</AppearanceStyleBoundary>
{isCompactLayout ? themedSidebarChrome : null}
<AppearanceStyleBoundary>
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<AppDiagnosticHost />
<ChangelogHost />
<QuittingOverlay />
</AppearanceStyleBoundary>
</View>
);
@@ -666,7 +684,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
{children}
</VoiceProvider>
</AppearanceProvider>
);
@@ -877,11 +895,15 @@ const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
function RootStack() {
const storeReady = useStoreReady();
return (
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<ThemedStack
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -896,6 +918,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack>
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function PluginSettingsRoute() {
const { serverId, pluginId, screenId } = useLocalSearchParams<{
serverId: string;
pluginId: string;
screenId: string;
}>();
const view = useMemo(
() => ({ kind: "plugin" as const, serverId, pluginId, screenId }),
[serverId, pluginId, screenId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,10 @@
import { useChangelogStore } from "./internal/changelog-store";
import { ChangelogSheet } from "./internal/changelog-sheet";
/** Mounted once by the app shell so any surface can call `openChangelog()`. */
export function ChangelogHost() {
const visible = useChangelogStore((state) => state.visible);
const close = useChangelogStore((state) => state.close);
return <ChangelogSheet visible={visible} onClose={close} />;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* What's new: the release notes, read from the repository's CHANGELOG.md and
* rendered in the app instead of sending the reader to a browser.
*
* Two things leave this module — the host the app shell mounts, and the command
* every entry point calls. Where the document comes from, how it is parsed, and
* how much of it renders at once stay inside; import from `@/changelog`, never
* a path within it.
*/
export { ChangelogHost } from "./changelog-host";
export { openChangelog } from "./open-changelog";
@@ -0,0 +1,241 @@
import { memo, useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { StatusBadge } from "@/components/ui/status-badge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolveAppVersion } from "@/utils/app-version";
import { openExternalUrl } from "@/utils/open-external-url";
import { useChangelog, type ChangelogState } from "./changelog-source";
import { useRevealedReleases } from "./use-revealed-releases";
import {
formatChangelogDate,
type ChangelogRelease,
type ChangelogSection,
} from "./parse-changelog";
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
const ThemedGift = withUnistyles(Gift);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
iconButtonChromeStyle({ size: "large", state });
interface ChangelogSheetProps {
visible: boolean;
onClose: () => void;
}
export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
const { t } = useTranslation();
const { state, reload } = useChangelog(visible);
const { count, showMore } = useRevealedReleases(visible && state.status === "ready");
const handleOpenWebsite = useCallback(() => {
void openExternalUrl(WEBSITE_CHANGELOG_URL);
}, []);
const header: SheetHeader = useMemo(
() => ({
title: t("changelog.title"),
leading: sheetLeadingIcon,
actions: (
<Pressable
onPress={handleOpenWebsite}
hitSlop={8}
style={websiteButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("changelog.openWebsite")}
testID="changelog-open-website"
>
<ThemedExternalLink
size={iconButtonChromeGlyphSize("large")}
uniProps={mutedColorMapping}
/>
</Pressable>
),
}),
[handleOpenWebsite, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
desktopMaxWidth={620}
desktopHeight="85%"
testID="changelog-sheet"
>
<ChangelogBody state={state} shownReleases={count} onShowMore={showMore} onRetry={reload} />
</AdaptiveModalSheet>
);
}
const SNAP_POINTS = ["85%", "95%"];
interface ChangelogBodyProps {
state: ChangelogState;
shownReleases: number;
onShowMore: () => void;
onRetry: () => void;
}
function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogBodyProps) {
const { t } = useTranslation();
const appVersion = useMemo(() => resolveAppVersion()?.replace(/^v/i, "") ?? null, []);
if (state.status === "loading") {
return (
<View style={styles.centered}>
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
</View>
);
}
if (state.status === "error") {
return (
<View style={styles.centered}>
<Alert
variant="error"
title={t("changelog.error.title")}
description={t("changelog.error.description")}
testID="changelog-error"
>
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
{t("common.actions.retry")}
</Button>
</Alert>
</View>
);
}
const visibleReleases = state.releases.slice(0, shownReleases);
return (
<View style={styles.releaseList}>
{visibleReleases.map((release) => (
<ReleaseView
key={`${release.version}:${release.date}`}
release={release}
isCurrent={release.version === appVersion}
/>
))}
{state.releases.length > visibleReleases.length ? (
<Button
variant="ghost"
onPress={onShowMore}
style={styles.showMore}
testID="changelog-show-more"
>
{t("changelog.showMore")}
</Button>
) : null}
</View>
);
}
interface ReleaseViewProps {
release: ChangelogRelease;
isCurrent: boolean;
}
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
const { t } = useTranslation();
const date = formatChangelogDate(release.date);
return (
<View style={styles.release} testID={`changelog-release-${release.version}`}>
<View style={styles.releaseHeading}>
<Text style={styles.version}>{release.version}</Text>
{isCurrent ? <StatusBadge label={t("changelog.installed")} /> : null}
<View style={styles.headingSpacer} />
{date ? <Text style={styles.date}>{date}</Text> : null}
</View>
{keyChangelogSections(release.sections).map(({ key, section }) => (
<View key={key} style={styles.section}>
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
</View>
))}
</View>
);
});
/** A release can repeat a section title; the occurrence keeps the key stable. */
function keyChangelogSections(
sections: readonly ChangelogSection[],
): { key: string; section: ChangelogSection }[] {
const seen = new Map<string, number>();
return sections.map((section) => {
const title = section.title ?? "";
const occurrence = seen.get(title) ?? 0;
seen.set(title, occurrence + 1);
return { key: `${title}:${occurrence}`, section };
});
}
const styles = StyleSheet.create((theme) => ({
centered: {
flex: 1,
minHeight: 160,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
releaseList: {
gap: theme.spacing[8],
paddingBottom: theme.spacing[4],
},
release: {
gap: theme.spacing[4],
},
releaseHeading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingBottom: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headingSpacer: {
flex: 1,
},
version: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
date: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
},
showMore: {
alignSelf: "center",
},
}));

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