Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andGeorge 59e63fbb5e test(muse): exercise child history replay without a live setup turn
Seed a historical child after closing an empty session so the replay
regression reaches resume even when live subagent projection is broken.
Both paths now fail independently with the reported negotiation error.

Co-authored-by: George <90179633+georgepullen@users.noreply.github.com>
2026-10-28 11:41:11 +00:00
George 51e844b540 test(plugins): launch fake muse through the provider command override 2026-10-28 11:38:22 +00:00
George 367a9d908b test(plugins): cover muse subsession negotiation through PluginRuntime 2026-10-28 11:38:22 +00:00
George eef309ec06 docs(plugins): require session.subsession negotiation before opening a child 2026-10-28 11:38:22 +00:00
George d75f60ebcc test(muse-provider): reach the subsession gate with a child-bearing fixture 2026-10-28 11:38:22 +00:00
George 6a4c05ce82 fix(muse-provider): negotiate session.subsession and gate child projection 2026-10-28 11:38:21 +00:00
Sebastian ArenaandMohamed Boudra 41582eb5fd Terminals survive daemon restarts, crashes and updates (#7644)
* M1: terminals survive daemon restarts: stop reasons, detached terminal worker, kill cleanup

restart_server_request.killTerminals / shutdown_server_request.keepTerminals and server_info.features.terminalsSurviveRestart; CLI daemon restart --kill-terminals and daemon stop --keep-terminals (capability-gated). The terminal worker runs detached over an owner-only, secret-authenticated socket and is adopted on daemon start. One kill cleanup contract (the reaper) by birth time, used by the daemon, the supervisor and stopDaemonInstance. Settings offers Restart and close terminals.

* M1: fix review findings: reaper session/table retry, short-command creation order, remote kill-restart cleanup report, stop test port

* M1: fix review findings: reaper-owned cleanup record, session member identity by terminal id, unreadable cleanup records, Settings restart error rendered in the card

* M2: worker protocol versions and draining, worker runs from its own copy, desktop upgrades keep terminals, out-of-date daemon notice, docs/terminal-worker.md

- Daemon classifies live workers by protocol: adopts current, drains one version old, shuts
  down older, leaves a newer one running and answers list/create with
  terminal_worker_incompatible (daemon status prints it). Never more than two workers.
- esbuild worker bundle (build:lib); a built daemon copies bundle, shell integration and
  node-pty into $PASEO_HOME/terminal-worker/<version>-<hash>/ and launches from it; copies no
  record names are pruned.
- Desktop app_update/version_mismatch stop with keep terminals on macOS/Linux; Windows and a
  daemon without the capability close them.
- Out-of-date daemon sidebar notice with Update button, worded by terminalsSurviveRestart.
- PASEO-13: unreadable worker records fail status and kill-restart retry.
- PASEO-14: a session member without a terminal id is unknown, not unrelated.
- Smoke test 46 with A/B installation fixtures and per-fixture CLI shims (PASEO-9 fold).

* M2: track in-flight worker requests without leaving an unhandled rejection

detach({ afterPendingRequests }) tracked each request with request.finally(), which rejects
with the request; a fire-and-forget send that timed out surfaced as an unhandled rejection
(worker-terminal-manager.test.ts). Track settlement only.

* M2: CI round 1 fixes: worker terminal manager in the MCP key test, status cleanup field, shared socket dir

- mcp-server.test.ts (new upstream test) built the worker manager with the old fork API;
  it now starts a real detached worker in a throwaway home.
- daemon-session.test.ts expected status without terminalCleanupUnconfirmed (added in M1).
- paseo-daemon.test.ts: a home under the scratch temp dir is too long for a unix socket, so
  the worker listens in the per-user paseo-<uid> directory, which outlives workers by design;
  the test now requires it to be empty instead of absent.

* M2: run Windows PowerShell by its install path for process identity

server-tests (windows-latest): bootstrap-provider-availability.test.ts starts a daemon with a
PATH that holds only git, and the terminal worker failed to start: 'cannot read its own process
identity: spawn powershell.exe ENOENT'. Process inspection (managed-processes.ts inspect and the
terminal worker's process table) now runs %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe.

* M2: lifecycle e2e plain stop uses the shutdown RPC on Windows

CI round 2, server-integration (windows-latest): 'stop and stop --force never signal processes
a stale or earlier-boot record names' called stopDaemonInstance without a shutdown channel,
which Windows refuses ('has no graceful shutdown channel'). Plain stops in the file now pass
one, as the CLI does.

* M2: native notice outlet, Windows desktop-update wording, worker cap counts unconfirmed cleanups, create recovers after a drain

* M2: phone sidebar mounts the callout slot (iOS notice verified), worker record reads retry transient errors

* M2: native notice preconditions use explicit working directories, one block per terminal

* M1: compact sidebar shows only native callouts on every platform; PTY round trips in the MCP key test get a 5 s window

* ci: pin Playwright browser jobs to ubuntu-24.04

The pinned Playwright (1.58) has no ubuntu-26.04 build, and forks whose
ubuntu-latest already resolves to 26.04 fail at browser install. Job names
are unchanged, so required-check contexts still match.

* ci: use ubuntu-latest for Playwright jobs, same as upstream

Reverts the ubuntu-24.04 pin; upstream main's Playwright shards pass on ubuntu-latest.

* fix(server): keep terminal worker record IO off the daemon event loop

* fix(server): restore terminal creation after worker failure

A disconnected terminal worker remained the current manager indefinitely, leaving dead sessions listed and every later create bound to a closed socket. Confirm process death before retiring the member and use the existing single launch path for replacement. Reconnect live workers and replay their screens while preserving terminal identity.

* test(server): verify orphan cleanup during worker recovery

Recovery reaps orphaned programs before a later daemon stop. The previous fixed-delay assertion raced that cleanup and failed when it completed promptly. Verify orphan removal, same-daemon replacement creation and cleanup on normal or forced stop instead.

* test(server): isolate Windows dependency fixture setup

Build the local package registry in setup so its npm packing processes do not consume the installation journey deadline. Install the platform package used by Windows runtime fixtures directly, avoiding an unused wrapper postinstall probe without a timeout.

* test(server): wait for pending watchers before disposal

Observation admission starts before asynchronous path setup reaches watcher subscription. Disposing at the admission counter races setup and never exercises late watcher cleanup. Wait for the subscriptions to be pending before disposing.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-28 18:04:29 +07:00
Mohamed BoudraandCarlos Precioso dee0d138e9 feat(worktrees): copy included local files before setup and agents (#8100)
* feat(worktrees): copy included local files before setup and agents

Keep local-file materialization in one built-in lifecycle hook so workspace setup and the first agent see the completed copy. Reuse the community plugin algorithm with the source checkout supplied by the worktree hook.

Co-authored-by: Carlos Precioso <github@precioso.design>

* test(plugins): make worktree lifecycle checks portable on Windows

Compare source checkout filesystem identity across Git and Windows path spellings. Exercise copy rollback with a destination file blocking a directory, since Git for Windows accepts directories as exclude files.

---------

Co-authored-by: Carlos Precioso <github@precioso.design>
2026-10-28 17:21:01 +07:00
Mohamed Boudra 986687b8fd feat(plugins): unify lifecycle hooks and await worktree initialization (#8081)
* refactor(plugins): make lifecycle hook timing explicit

Use one registration method and subject.operation.step names so plugin authors can read whether an operation waits. Preserve old registrations through a single SDK adapter and keep timing and payloads unchanged.

* fix(plugins): align hook notes and example fixtures with release contracts

Keep the changelog inside its versioned beta entry for app parsing. Run current plugin examples against the checkout version so their declared requirements remain valid.

* docs(plugins): align translated hook dependency guidance

* fix(plugins): align hook exports and release documentation

* feat(plugins): use lifecycle positions and await worktree initialization

* test(plugins): cover first-agent worktree initialization

* test(plugins): verify rollback against workspace catalog entries
2026-10-28 17:04:52 +07:00
Mohamed Boudra eee70e7fef Show a lone tool call as itself and group from the second call (#8109)
* fix(app): show a lone tool call as itself and group from the second call

A run of one tool call or thought renders as the call. When a second call
arrives the row becomes a group that keeps the first call's id, so the row
key stays put. Tool call and group open state move into a per-agent store:
opening a lone call also opens its future group without notifying the
timeline, so a click re-renders only that tool call.

* test(app): cover lone tool calls turning into groups

Desktop journeys check that an opened lone call keeps its details open inside
the new group, that clicking it renders no timeline component, and that the
group header lands where the call was. Compact covers the call sheet and a
closed group.
2026-10-28 16:34:58 +07:00
paseo-bot[bot] d5a27e0164 fix(server): wait for native watcher delivery before declaring it silent (#8107)
* test(server): expose macOS observation startup failures

* test(server): repeat macOS startup to capture the intermittent failure

* fix(server): probe watcher liveness until native event delivery starts

* test(server): use the production deadline for watcher startup regressions

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 09:29:36 +00:00
8e7e8cce21 fix(app): retain plugin state across host disconnects (#8105)
Keep client plugin installations and local UI state alive through transient host disconnects. Reconcile changed bundles and removals on reconnect and refresh settings changed while offline. Connection switching still replaces the client and reinstalls plugins.

Preserves the implementation from #6731, adds a failing-before/passing-after real browser regression, and corrects the English and Chinese lifecycle documentation. Verified with focused tests, browser reconnect journeys, Android debug offline interaction, root checks and green current-head CI.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 15:55:32 +07:00
Mohamed Boudra 36fc4f0b43 chore(ci): remove Android module tests from ordinary CI (#8102)
Native unit tests require an Android prebuild and Maven downloads that block otherwise completed app checks. Keep the explicit local test command while removing its CI invocation and dedicated Java setup.
2026-10-28 14:54:25 +07:00
Mohamed Boudra 4dae66eb8c docs(hub): document reviews when pull requests leave draft (#8104) 2026-10-28 14:26:20 +07:00
Mohamed Boudra d6d5a266ad Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer

The sheet tab body padded the bottom safe-area inset, and the composer dock
padded it again, so the parent callout in a subagent sheet floated a full
navigation-bar height above where the composer sits in a tab.

The sheet body (SheetBottomInset) now owns the inset: it pads it once and
tells its content none is left, so the dock stays host-agnostic. Overlay
sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on
the far side of their portal, so ones opened from sheet content still clear
the screen edge.

The callout takes the composer's rail and shows the parent's provider icon
through StatusIcon: no icon box, with the running ring, attention dot or
needs-input alert on the icon.

* fix(app): give the attachment lightbox its own safe area

An RN Modal passes its call site's context straight through, so a lightbox
opened from a sheet tab read the bottom inset the sheet body had already
spent (0) and drew over the navigation bar. The modal is its own window:
it now gets its own SafeAreaProvider, which measures that window, and the
image area reads its insets below it.

floating-panels.md records the rule for overlays: read insets where the
overlay draws.

* feat(app): open a subagent in place of the subagents list on compact

On phones, choosing a subagent from the subagents list now replaces the list
inside the same sheet instead of stacking a second sheet over it. Back returns
to the list, Close closes both, Android Back steps subagent -> list -> closed,
and Open parent lands on the pane the list was opened from (for a nested
subagent, its parent's sheet).

- Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops
  a page via `goBack`, and a page whose definition disappears falls back to
  the list.
- One sheet pane (`screens/workspace/sheet-pane`) renders the header, body,
  bottom inset and focus for both containers: the workspace's detached sheet
  tabs and the in-list menu page. Only the container differs.
- The header drags the sheet through `SheetDragArea`; compact menu sheets use
  the shared `SheetBackground`/`SheetHandle` chrome.
- `selectSheetTab` describes the sheet a reveal would show without opening it.

* fix(app): put the parent callout on one row

The Open parent button already says what the callout is, so the Parent label
goes. The parent's title shrinks to one line before the button does, so Open
parent never wraps or leaves the row.

* refactor(app): register panels once in the workspace screen

Every reader of the panel registry called ensurePanelsRegistered, which
imports every panel module. The panels render those readers (the agent panel's
subagent track and parent callout, the new-tab panel's launcher, the diff
panel's review surface), so each reader closed an import cycle through
register-panels.

The workspace screen, the only route that renders a registry reader, now
registers the panels before anything under it renders, and the readers import
only the leaf panel-registry. The pane content test registers a probe panel for
real instead of mocking the registry.

* test(app): transform the markdown WASM factory when Vite versions its URL

Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made
Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did
not match, so the test file failed to import.
2026-10-28 13:55:41 +07:00
Mohamed Boudra 460ba8e8a3 chore(store): add phone and tablet store screenshots (#8099)
Five panels for the App Store and Google Play in iPhone, Android phone,
iPad 13-inch and Android tablet sizes, rendered from native captures of
the real app against a staged demo host.

The renderer keeps the captures and the store art reproducible: it
resizes every capture with Lanczos to the exact pixels it fills because
Chrome's own downscaling made earlier exports soft, and it fails when a
headline or subline wraps differently, because the device is placed from
that fixed text height. The README is the recapture runbook and the
store-screenshots skill points agents to it.
2026-10-28 13:55:36 +07:00
Mohamed Boudra 7a0c6fbd82 fix(app): remove blank space above Android turn footers (#8088)
Android ships generated Markdown props without code.fontScale, so native measurement wraps inline code at a larger size than drawing. Regenerate both Android code structs and enforce generated-header parity for both platforms.
2026-10-28 13:51:41 +07:00
Mohamed Boudra bede2b90ed fix(app): truncate agent sender attribution to one line (#8092)
Constrain the sender label to the message width so long agent names do not wrap above received messages. Keep the line limit opt-in on Button and cover compact chat widths with a real-browser regression.
2026-10-28 13:50:16 +07:00
Mohamed Boudra befc3d5663 fix(app): restore expansion of long native user messages (#8089)
Native hidden overflow capped the measured content at the preview height, so long messages never offered Show more. Use the native scroll layout mode on the existing View to measure full content while preserving clipping, and store only whether it overflows.
2026-10-28 13:46:43 +07:00
paseo-bot[bot] 53bbd7522d test(server): wait for pending watchers before testing disposal (#8091)
Observation admission precedes asynchronous path resolution. Wait for subscriptions to start before disposal so the test consistently exercises late watcher cleanup.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 06:13:41 +00:00
paseo-bot[bot]andElsa ce412d04c3 Keep docked navigation sizes consistent in narrow windows (#8052)
* fix(app): unify layout and density breakpoints

Align md styles with the existing 600pt compact/wide boundary so
600-719pt tablet windows no longer mix wide layout with compact metrics.
Keep the 500pt composer threshold local to its measured content width.

Let Settings navigation shrink before its detail pane falls below the
existing 400pt target, while retaining the preferred 720pt split budget.

Validated workspace typecheck, lint, formatting, real Chromium resize
checks, Duo portrait and landscape, and existing sidebar geometry tests.

* test(app): cover consistent docked navigation and readable Settings

Compare narrow and wide navigation density and Settings detail space
through the real browser journey, including compact Settings after resize.
The regression fails with main's split layout and density boundaries.

Co-authored-by: Elsa <zeyugao@outlook.com>

---------

Co-authored-by: Elsa <zeyugao@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 06:07:57 +00:00
paseo-bot[bot]andyywl de45902652 fix(server): show reused plugin children as running again (#8080)
* fix(server): report reused plugin children as running again

Child turn starts were discarded after attachment, leaving the previous completed status visible during later work. Translate started to running for live events and ordered history replay.

Co-authored-by: yywl <yywl@linux.0392yy.cn>

* test(server): wait for Git observation before completing fetch

The structural fallback test advanced its debounce timer before asynchronous watcher setup and fetch completion. Wait for those stages so slow filesystem setup does not turn the intended refresh assertion into a scheduling race.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: yywl <yywl@linux.0392yy.cn>
2026-10-28 04:55:10 +00:00
abc23d7721 perf(plugins): minify client bundles while preserving interop and notices (#8076)
Reduce plugin client payloads with whitespace and syntax minification after eager host-export interop. Preserve identifiers, lowered async behavior and dependency license notices; leave server bundles readable.

Verify worker-produced code through registration, byte-reduction, immutable-export and retained-notice regressions. Carries forward #7625 on an upstream branch.

Pin Playwright-installing CI jobs to Ubuntu 24.04 after the ubuntu-latest rollout selected an unsupported browser-install platform.

Follow-up payload review motivated by Matt Cowger's plugin-loading discussion: https://discord.com/channels/1481169421832814616/1563217536269680744

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 09:50:29 +07:00
paseo-bot[bot] 2c23cd4927 test(cli): let test daemons allocate their listening ports (#8058)
Reserve-and-release port selection races other listeners during daemon startup. Bind port zero and use the existing daemon instance endpoint for readiness and callers.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 21:27:30 +00:00
paseo-bot[bot]andMatteo Pietro Dazzi de50243885 Keep model-discovery errors away from picker edges (#8039)
* fix(app): pad the model picker empty state on all sides

The empty state carried vertical padding only, so the provider refresh
error it renders wrapped from edge to edge and read as attached to the
model picker's left border on web and mobile.

One padding value keeps the message off the border on every platform.

* test(app): keep model discovery errors inside picker edges

Exercise a real catalog deadline through the existing Pi RPC fixture at desktop and compact browser widths. Both gutter assertions fail without the shared padding fix.

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>

---------

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 15:59:03 +00:00
Mohamed Boudra 5b7b8d6e81 fix(app): regenerate iOS Markdown inline code props (#8038) 2026-10-27 22:44:49 +07:00
Mohamed BoudraandClaude 25721c7847 fix(app): keep grouped reasoning closed when Expand thinking is off (#8042)
Thoughts are grouped with tool calls only while Expand thinking is off,
yet opening a group rendered each thought with `defaultExpanded`, so the
reasoning text popped open against the setting. Grouped thoughts now open
like any other call: a closed Thinking row the reader clicks.

The folded history also treated the latest retained turn as live whenever
the agent was active without a known turn id, which unfolded the previous
turn until the new turn's rows arrived. Without head rows, the latest turn
stays live only while the active run is that turn.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-27 22:34:13 +07:00
78f580f9e8 perf(plugins): reuse verified bundles and fix repeated timeline preparation (#8040)
Reuse verified host-scoped plugin bundles across client launches and reconstruct complete catalogs when unchanged code is omitted. Keep store ownership on the connected client, including adopted bootstrap clients, and persist in the background with bounded writes.

Remove catalog catch-up deferral and fix repeated timeline preparation that promoted cached completed history into a live head. This preserves completed plugin-rendered chat when catalog installation precedes timeline catch-up.

Carries forward #6732 on an upstream branch, with the original hash/cache contribution and integration attribution preserved.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-27 22:00:23 +07:00
Mohamed Boudra 8419c21227 docs(changelog): explain Claude subscription-limit workaround 2026-10-27 14:43:03 +01:00
paseo-ai[bot] 5f014498c7 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 13:32:06 +00:00
Mohamed Boudra 4495bda146 chore(release): cut 0.14.0-beta.1 2026-10-27 14:21:19 +01:00
Mohamed Boudra 484c5ba224 docs(release): prepare 0.14.0-beta.1 notes 2026-10-27 14:15:48 +01:00
Mohamed Boudra 1b8dbc38ab fix(app): preserve chat reading position across older pages (#8026)
* fix(app): preserve reading position when older turns fold

Item-based history pages can complete and collapse the row at the reading position. Transfer its anchor to the folded turn host so reaching history start does not drain older pages. Also omit reasoning from action summaries and align sender attribution with its message bubble.

* fix(app): preserve the reader when older pages extend tool groups

Resolve grouped calls and folded turns through one presentation-owned row lookup. Keep retained group indexing off the streaming path and verify item-page group replacement against its failing baseline.

* fix(app): preserve message previews when opening turns

Keep the wrapper child structure stable so React does not remount the message fragment when the indent rail appears. Remounting reset preview measurements and briefly removed the fade and Show more control.

* fix(app): separate show less from expanded message text
2026-10-27 19:25:13 +07:00
paseo-bot[bot] 694696e82c fix(server): keep background Git fetches from opening login prompts (#8019)
* fix(server): keep background Git fetches from opening login prompts

Git tries graphical askpass before consulting GIT_TERMINAL_PROMPT, so passive fetches repeatedly opened a login despite terminal prompting being disabled. Disable askpass and credential-manager interaction for this fetch while preserving stored credential lookup.

* test(server): make fetch and ACP expectations match supported Git and timeline behavior

Newer Git also creates origin/HEAD on the first fetch, so assert the authenticated branch result without excluding that valid additional ref. Main now qualifies ACP timeline identities by chunk kind while preserving the native messageId; update the plugin round-trip test to check that contract.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 12:00:55 +00:00
paseo-bot[bot]andJacek Tomaszewski a8032a1502 fix(app): open outside folder links without breaking Files (#7726)
Outside folder links were routed to the workspace-scoped Files explorer,
which kept an access error until Retry. Route explicit outside folders
to the local file manager or show an outside-workspace message, while
preserving existing file and inside-folder behavior.

Refs #7513

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-10-27 10:57:58 +00:00
paseo-bot[bot]andYanlin dc07efe937 Keep plugin ACP reasoning separate from assistant replies (#7272)
* fix(plugin): keep ACP reasoning separate from replies

Scope explicit timeline identities by chunk kind so a provider sharing a native message ID cannot merge reasoning into the assistant reply. Preserve the native assistant messageId for consumers.

Co-authored-by: Yanlin <127009+cncolder@users.noreply.github.com>

* test(plugin): cover kind-qualified ACP history identities

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Yanlin <127009+cncolder@users.noreply.github.com>
2026-10-27 09:53:42 +00:00
paseo-ai[bot] 36892d5be7 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 09:31:18 +00:00
Dong Hyeon An b9f624f72e fix(app): update xterm to prevent shared-atlas glyph corruption (#8013)
Use upstream atlas invalidation fixes and cover ordinary output and shared-atlas clearing with real-WebGL pixel regressions.\n\nRelated to #6506; does not close the broader rendering issue.
2026-10-27 09:22:15 +00:00
Mohamed Boudra 50623965b4 Make line wrapping responsive in large diffs (#8006)
* perf(app): make diff line wrapping responsive on large changes

Wrapping eagerly shaped every expanded line before the viewport could paint. Estimate off-screen heights and measure rows as they enter the window, preserving the scroll anchor as geometry becomes exact. Add shared layout and browser benchmarks to track toggle latency and shaping cost.

* fix(app): preserve wrapped diff anchors across bounded relayouts

Resizing can move the viewed row outside the old materialization window. Keep its estimated wrap geometry available to anchor resolution, and measure the anchored row in the same layout pass so inaccurate estimates cannot discard the exact fragment or pixel offset.
2026-10-27 16:09:53 +07:00
Mohamed Boudraandluke ef58ace424 Fix history compatibility and turn presentation regressions (#8010)
* fix(client): preserve history loading on older hosts

Whole-turn paging is optional, but requiring its capability blocked existing history reads before any request was sent. Negotiate the paging enhancement at the client boundary while preserving item-based requests for older daemons.

* fix(app): preserve row ownership during late turn updates

Late lifecycle events retagged the newest row even when they updated an earlier call, causing the next update to append a duplicate tool ID. Assign turn membership only to rows written by the event before finalizing unrelated reasoning.

Co-authored-by: luke <luopeihuan@gmail.com>

* fix(app): preserve reasoning continuity and turn expansion controls

* fix(app): hide reasoning inside collapsed turns

---------

Co-authored-by: luke <luopeihuan@gmail.com>
2026-10-27 15:49:42 +07:00
Mohamed Boudra 8a542048b6 Let plugins contribute MCP servers on every agent session open (#7983)
* feat(plugins): contribute launch-only MCP servers at session open

* docs(plugins): explain saved configuration and launch-only MCP servers
2026-10-27 15:20:06 +07:00
Mohamed Boudra 6b7d49f0d8 test(server): keep responsiveness gates independent of runner scheduling (#8002)
Raw timer gaps and elapsed callback or GC spans include Windows runner preemption. Share callback CPU accounting across the daemon hot-path probes and retain deterministic async admission, real yields, and sync-IO tracing at filesystem boundaries.
2026-10-27 07:35:44 +00:00
paseo-ai[bot] 07007a485d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 07:20:13 +00:00
Mohamed Boudra 26ee20b371 fix(ci): hoist expo-module-scripts so root overrides reach its Expo peers (#7998)
`npm audit signatures` in the quality job fails on some runners with
EMISSINGSIGNATUREKEY for @react-native/babel-preset@0.88.0. Nothing in
the repo uses React Native 0.88: the package came from a second Expo SDK
that npm placed under packages/expo-two-way-audio/node_modules because
expo-module-scripts was declared there. Root `overrides` are not applied
to packages nested under a workspace, so jest-expo's `expo@*` peer
resolved to the latest SDK and pulled 200+ duplicate packages into the
lockfile.

The failure itself is a CDN cache problem: one Cloudflare edge (ORD,
which serves GitHub runners in Azure northcentralus) cached the
attestations document for that version seconds after publish, before
the publish attestation existed, and keeps serving the provenance-only
document for a year. Every quality job that lands in that region fails;
every other region passes. The audit code path checks the registry
signature before attestations, so the key list is fine and reruns only
succeed by landing elsewhere.

Declaring expo-module-scripts in the root devDependencies puts it under
the overrides, which removes the nested SDK and the poisoned URL from
the lockfile. The module's tsconfig points its typeRoots at the hoisted
copy; its scripts resolve `expo-module` from the root `.bin`.

Closes #7730
2026-10-27 07:10:54 +00:00
Mohamed Boudra b12eefb28f Keep conversation turns distinct and history stable (#7990)
* fix(app): keep autonomous turns distinct and readable

Hidden system prompts merged autonomous work into the previous response and reopened completed turns. Preserve canonical turn boundaries and expose their triggers, while making received messages attributable and expanded turn details readable in one step.

* fix(timeline): mount history at actual run boundaries

* fix(timeline): load and retain complete available turns

* fix(app): summarize actions and preserve conversation events

* test(timeline): verify whole-turn paging stays responsive

* fix(app): group reasoning according to thinking preference

* test(app): align history journeys with whole-turn loading
2026-10-27 13:57:17 +07:00
Mohamed Boudra 3a1b146690 Guard daemon modules against synchronous Node IO (#7991)
* chore(server): guard against synchronous Node IO in daemon modules

* chore(server): retire unused sync IO exceptions automatically

* chore(server): remove the attachment IO exception after async materialization
2026-10-27 13:41:54 +07:00
Mohamed Boudra 2ce6681d6e Keep image attachments responsive during prompts and replay (#7954)
* fix(server): keep image materialization responsive

Decode and hash attachments in bounded yielding units, deduplicate before writing, and publish complete private files before provider timeline events. Await mapping through existing replay boundaries and serialize notification dispatch so turn completion cannot overtake an image.

* fix(server): reject attachment starts closed during conversion

Keep interruption terminal for prompts still being materialized, and reject a start if its session closes before provider submission. Cover both providers with real large-image conversions and make history reconciliation tests await the async timeline capability.

* fix(server): publish OpenCode snapshots before live suffixes

Commit snapshot cursors and publish each event together. Holding those events until attachment IO completed let live deltas overtake text that had already advanced the cursor. Require snapshot publishers and exercise text and reasoning interleaving against real image materialization.
2026-10-27 13:15:38 +07:00
Mohamed Boudra 63ed15e933 test(server): make the reconciliation responsiveness probe independent of idle waits (#7988)
* test(server): measure reconciliation callback work instead of idle gaps

Timer gaps include Windows runner scheduling waits. Keep the 20 ms gate on actual main-thread callback spans and GC, retaining raw gaps and thread CPU as diagnostics.

* test(server): exclude preemption within reconciliation callbacks

Zero-CPU scheduling waits also occur inside callbacks. Budget charged thread CPU and independently require async filesystem admission so synchronous inspection waits remain a deterministic regression.
2026-10-27 05:51:49 +00:00
Mohamed Boudra 9e4aab16cc fix(server): keep the daemon responsive during plugin builds (#7967)
* fix(server): keep plugin compilation off the daemon event loop

TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin.

* fix(server): reuse the compiler worker during plugin build batches

Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture.

* fix(server): retire the compiler worker after build errors

A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work.

* fix(server): preserve compiler error metadata across workers

* test(server): isolate plugin validation in the loop-gap fixture

Exercise erased TypeScript syntax with a small runtime bundle so parser validation, not multi-MiB result transfer, determines the CI fixture. Keep the 20 ms gate and report event-loop active/idle time for scheduler diagnostics. Resolve the development loader at module initialization.

* test(server): probe event-loop turns during plugin compilation

Windows timer wake-up lateness measured 22 ms while the loop was idle for 21.96 ms. Use check-phase callbacks to measure loop availability, retaining the raw 20 ms limit and the failing base-branch reproduction.

* test: run plugin responsiveness probe in serial integration suite

* test: diagnose compiler probe gaps with per-thread CPU readings

* test: distinguish compiler CPU blocking from runner scheduling

* test: classify the largest compiler CPU accounting window

* test: isolate compiler responsiveness from harness GC and idle accounting

* test: measure callback blocking with a high-resolution clock
2026-10-27 10:30:41 +07:00
Mohamed Boudra 173fca3c0b Keep the daemon responsive during Claude history replay (#7940)
* fix(server): keep Claude history replay responsive

Load persisted history through session readiness rather than the constructor. Async descriptor-owned replay preserves ordering while serving other requests, and rebind retires the old query before rebuilding history and rewind state.

* fix(server): recover Claude history readiness after rebind errors

* test(server): calibrate Claude replay fixture for CI

* fix(server): report Claude rewind rebind failures to callers
2026-10-27 10:23:59 +07:00
Luke LaBonte 7f6a805e0e feat(app): choose the composer send shortcut (#5503)
Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests.

Co-authored-by: Luke LaBonte <contact@lukelabonte.com>
2026-10-27 10:05:16 +07:00
Mohamed Boudra 9bc998fa38 fix(server): keep Git observation and worktree IO off the event loop (#7943)
* fix(server): keep Git observation and worktree IO off the event loop

Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning.

* test(server): verify Git trace records without filesystem probes

* fix(server): establish Git identities at observation setup

Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order.

* fix(server): retain literal symlink targets in Git event identity

Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO.

* fix(server): preserve Windows worktree path spelling

* fix(server): retain native aliases in worktree ownership

* test(server): write Git IO trace markers asynchronously
2026-10-27 09:57:35 +07:00
Mohamed Boudra 67a2459254 fix(server): keep project config work off the daemon hot path (#7944)
Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
2026-10-27 09:56:51 +07:00
Mohamed Boudra f14a4cc112 fix(server): keep password-protected config reload responsive (#7959)
* fix(server): keep password-protected config reload responsive

Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback.

* test(server): adapt config consumers to async service-port reads

Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows.
2026-10-27 09:56:47 +07:00
Mohamed Boudra a25f4f5065 perf(server): serve timeline pages from retained projection (#7958)
* perf(server): select timeline pages from retained projection

Keep page selection and source coverage indexes with the retained projection so bounded fetches and last-message lookups do not copy or reproject history. Omitted after limits use the existing 200-item store default; explicit zero remains unlimited for released CLI clients.

* test(server): keep timeline page oracle outside production builds
2026-10-27 09:56:42 +07:00
Mohamed Boudra 6077bc6219 Keep daemon diff highlighting responsive and bounded (#7945)
* fix(server): keep diff highlighting responsive and bounded

Tiny patches previously parsed both full source versions synchronously. Yield between parser work units and admit source by UTF-8 bytes, keeping large patches renderable without tokens. Cancel obsolete highlighting when a newer diff supersedes it.

* fix(server): keep diff cancellation with its requester

Independent commit viewers must not cancel each other. Use request-source cancellation for commit highlighting and cache invalidation for checkout highlighting. Oversized source versions use the existing budgeted hunk reconstruction so large lockfiles retain useful syntax tokens.
2026-10-27 09:56:36 +07:00
Mohamed Boudra 9c7d4ace65 fix(server): reject oversized provider stdout before parsing (#7960)
Admit raw UTF-8 line bytes before accumulation and fail the provider session explicitly on overflow. Share line framing so fragmented JSONL input no longer repeatedly scans and copies the complete prefix.
2026-10-27 09:56:31 +07:00
Mohamed Boudra 8d971876bf fix(omp): keep transcript replay responsive (#7948)
* fix(omp): keep transcript replay responsive

Index transcript branches in bounded asynchronous chunks and retain payloads only for the active chain, so large histories do not block unrelated daemon requests.

* test(omp): enforce responsive history replay in CI

Use a smaller structured transcript and a short selected chain to expose whole-file parsing without making large-output GC part of the CI gate. Disable unrelated provider discovery and route the regression through the existing integration job.

* test(ci): include OMP replay in the routing contract
2026-10-27 09:56:27 +07:00
Mohamed Boudra fc0cbbb323 fix(server): keep workspace reconciliation responsive on slow paths (#7941)
* fix(server): keep workspace reconciliation responsive on slow paths

Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan.

* fix(server): wait for healthy reconciliation inspection capacity
2026-10-27 09:56:21 +07:00
david828019-ossandQTom 64b0afe010 fix(claude): identify SDK processes with the Paseo entrypoint (#7976)
Co-authored-by: QTom <qt@truesightai.com>
2026-10-27 09:38:49 +07:00
Mohamed Boudra 10bc10c99f Let plugins give specific agents tools (#7938)
* feat(plugins): attach tools to individual agent sessions

* feat(plugins): reopen idle agent sessions after hook changes

Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK.

* docs(plugins): show how to attach tools to agents

* test(agent): update permission follow-up adapter
2026-10-27 09:23:50 +07:00
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
paseo-bot[bot]andliyang 4f9d9d6de2 Keep ACP terminal output from stalling daemon requests (#7953)
* fix(acp): trim terminal output in linear time

* test(acp): reproduce terminal output stalls with a larger workload

The 128 KiB workload completes inside the regression deadline on faster
machines even with quadratic trimming. One MiB exceeds that deadline on
the old implementation while the bounded UTF-8 suffix implementation
finishes promptly.

Co-authored-by: liyang <liyang@msh.team>

---------

Co-authored-by: liyang <liyang@msh.team>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-26 17:04:42 +00:00
Mohamed Boudra 1f3266e2ba test(server): remove connected-host delivery timing assumptions (#7961)
CLI startup is not bounded by the recipient quiet window. Exercise the three delivery sources in separate windows and retain exact timeline and durable receipt assertions. Await permission-finish cleanup after dispatch acknowledgment using the integration cleanup budget.
2026-10-26 23:56:19 +07:00
Mohamed Boudra 28834a91ae fix(server): tolerate brief Windows atomic replace failures (#7955)
A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics.
2026-10-26 23:13:25 +07:00
Simon Gurcke e3bbed3c73 fix(pi): queue prompts until manual compaction finishes (#7848)
* fix(pi): wait for manual compaction before sending prompts

* test(pi): simplify compaction regressions and stabilize live smoke
2026-10-26 14:02:44 +00:00
Mohamed Boudra 42d74c2546 fix(ci): retry transient Apple staple connection failures (#7933) 2026-10-26 19:20:17 +07:00
Mohamed Boudra 678fe308db chore(release): reconcile 0.13.2 metadata with main (#7931)
* docs(release): add 0.13.2 native patch notes

(cherry picked from commit 870c969fdb)

* chore(release): cut 0.13.2

(cherry picked from commit d2941157a8)

* chore(release): refresh Nix dependency hash for 0.13.2
2026-10-26 18:35:29 +07:00
Mohamed Boudra 287ca54749 fix(app): enlarge sidebar kebab tap targets on compact layouts (#7928)
On phones the project and workspace row kebabs were 24px and 18px boxes
with a 14px glyph, too small to hit reliably. Compact layouts now use a
32px box and the density-sized glyph (18px); desktop sizes are unchanged.
Negative margins keep the dots on the existing right rail and the rows at
their 44px compact height.
2026-10-26 18:12:56 +07:00
paseo-ai[bot] 902b0c7a16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 08:55:29 +00:00
Mohamed Boudra 428ef707ad fix(app): demand the host directory from the workspace route (#7918)
A workspace restored at launch with no open chat had nothing demanding its host's directory on a phone, so the deck waited on hydration that never started. The workspace route now keeps its host's directory demanded while mounted, through the same hook the agent route uses.
2026-10-26 15:46:10 +07:00
Mohamed Boudra e277656cbd fix(app): clean up native animation and layout lifecycles (#7914)
Worklets 0.10.4 and Reanimated 4.5.5 while keeping the stale-view guard from #7907; backport the Enriched Markdown settled-row measurement fix (upstream #924); release each bottom sheet's Dimensions subscription on unmount.
2026-10-26 15:46:04 +07:00
Mohamed Boudra e958540b0a fix(app): remove SVG prop and nested button warnings on web (#7915)
* fix(app): prevent web SVG prop and nested button warnings

Consume Unistyles mapping props at the wrapper boundary and use web accessibility attributes for decorative rings. Register composer track rows as menu items so nested actions stay valid and keyboard selection remains available.

* test(app): select archive actions by their menu role
2026-10-26 15:06:02 +07:00
Mohamed Boudra 033b2cdfc2 fix(app): keep command suggestions visible as the composer grows (#7910)
Long command descriptions exhausted the available space above a tall draft, leaving no complete selectable row. Bound the preview and reserve list space. Let the popover own its composer anchor so layout changes refresh its position while suggestions remain open.
2026-10-26 14:24:38 +07:00
Mohamed Boudra 74a7e5803b Choose voice providers and local speech models from a Voice settings page (#7295)
* feat(voice): add a Voice settings page with provider choice and a local model catalog

Each host gets a Voice page under host settings. Dictation and voice mode
each pick a provider (Local or OpenAI) and a model. Provider settings sit in
one group of collapsible rows: local speech-to-text and text-to-speech list
every catalog model with its size, languages and description before download,
and OpenAI takes a write-only API key and an optional base URL.

The local catalog grows from Parakeet/Kokoro to Whisper, SenseVoice, Dolphin,
GigaAM, Parakeet Japanese, multilingual Kokoro, Kitten and Piper voices. Each
entry declares its sherpa engine family, so the recognizer and TTS build the
matching model config and the downloader verifies the files that family needs.
Kitten v0.8 is left out: it crashes the pinned sherpa-onnx 1.12.28.

Voice mode is now opt-in. The default lived only in code (true), never in
config.json, so hosts that never toggled it find it off after upgrading.

OpenAI speech-to-text resolved one model for both features, with voice mode's
choice taking precedence, so dictation silently used voice mode's model. The
resolved config now carries a model per feature; STT_MODEL still overrides both.

Rows revealed under a collapsible settings row are a nested density level set
by the container: tighter padding, smaller text, and xs controls, with
controls taking their size from the row instead of each caller.

* test(app): pre-bundle the JSX dev runtime for browser tests

Compiling test JSX with the automatic runtime imports react/jsx-dev-runtime. Vite discovered it mid-run on a cold cache and reloaded the page, which failed the first browser test file to import it.

* refactor(voice): keep speech provider settings at each use site

Store credentials and endpoint options beside the speech feature that consumes them so saved page values cannot be shadowed by a separate provider block. Normalize existing config on load and mirror the inline shape on config reads and patches.

This is the config, resolver and wire phase; the provider sheet follows separately.

* fix(voice): preserve saved keys when resaving provider settings

Forms send the selected provider on each save but cannot read back its API key. Merge patches for the existing provider so these saves retain credentials; replace the site only when the provider changes.

* feat(voice): configure each speech use site from a provider sheet

The Voice page now lists Speech-to-text, Listening and Speaking as one-line
summaries. Tapping one opens a sheet with the provider choice and only that
provider's settings: the local catalog (size, languages and description
before download, download state per model) or the OpenAI key, base URL,
model and language or voice.

The sheet's form model sends only the fields the user changed, so re-saving
never clears a saved key. Fields an env var decides are read-only with a
"Set by" hint; the API key and base URL stay editable because a saved value
wins over the env fallback. Save failures show a toast and keep the sheet
open with the input.

E2E daemons pin speech providers through env vars to avoid model downloads,
so the browser spec covers the locked-provider path, the key fallback and
the failed save; provider switching is covered by the form model tests.

* refactor(voice): press settings and catalog rows through PressHighlight

Settings rows that lead somewhere and local model rows now use the standard
row press primitive, so native shows the finger-down flash the rest of the
app's rows have. Web hover and press styling are unchanged.

* fix(voice): preserve STT language for local providers

Language belongs to every STT use site. Preserve legacy local language settings through config loading and saving, resolve them without provider gating, and expose them in the provider form.
2026-10-26 14:19:02 +07:00
Mohamed Boudraandbjspi 88bd1cf05a feat(app): long-press Send to queue or send now on touch screens (#7897)
Touch users had no way to reach what Cmd/Ctrl+Enter does: queue when
Default send is Steer or Interrupt, send now when it is Queue. Holding the
Send button now runs that same action on every platform, with a haptic on
native and an accessibility hint naming it. Tapping is unchanged.

Both paths go through runAlternateSendAction, which now falls back to the
default send when no turn is running, so holding Send while idle still
sends and the keyboard path no longer carries its own gate.

Co-authored-by: bjspi <43368639+bjspi@users.noreply.github.com>
2026-10-26 14:04:30 +07:00
Mohamed Boudra 95c8491e2e test(server): await finish cleanup and expose completion persistence errors (#7909) 2026-10-26 14:03:51 +07:00
5e53bb78c2 fix(app): keep your place in the Settings sidebar (#7899)
Keep sidebar scroll position for the Settings visit across app and host section navigation. Reject stale scroll callbacks from replaced sidebars and reset the offset when leaving Settings.

Preserve the existing sibling routes and host-selection behavior. Consolidate the restoration work and broader regression coverage from the predecessor pull requests.

Closes #1532.

Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com>
Co-authored-by: shampoo <wn990916@gmail.com>
2026-10-26 13:53:01 +07:00
Mohamed BoudraandClaude Fable 5.1 ff31b6e5b9 fix(android): skip Reanimated sync prop updates for unmounted views (#7907)
Backport of software-mansion/react-native-reanimated#10435 (shipped in Reanimated 4.7.1) to the 4.5.1 that Expo 57 pins. On React Native 0.86 Reanimated applies synchronous prop updates by reflection and skips the view-existence guard, so every view tag that unmounted with buffered updates threw RetryableMountingLayerException on each event dispatch and printed a full stack on the UI thread. The guard checks MountingManager.getViewExists first and skips writes to missing native views.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:50:46 +07:00
Mohamed Boudra 78a3083fea perf(app): avoid stream updates in hidden chats (#7901)
Retained chats already preserve their last visible transcript. Keep their stream-tail selection stable while inactive so incoming chunks do not schedule React work; reveal reads the current model tail.
2026-10-26 13:49:56 +07:00
paseo-ai[bot] 443f1501e4 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 06:49:41 +00:00
97ed96bdf6 release: prepare 0.13.1 sidebar startup hotfix from stable (#7902)
* fix(app): keep the sidebar alive when a workspace entry has not hydrated (#7896)

ProjectBlock asserted that every placement in a project has an entry in
workspaceEntriesByKey. A retained sidebar that is inactive (compact layout
with the sidebar closed) or still waiting for sessions keeps an empty map,
and Show background stops the project list from being pruned to hydrated
entries. From the eighth workspace on, selectSidebarGroupVisibility asked
for the status of a missing entry and the app crashed on start.

Count a missing entry as Done. It renders no row, so it should not claim a
slot past the limit.

Closes #7869

Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6bc4b73c42)

* docs(release): add 0.13.1 hotfix notes

* chore(release): cut 0.13.1

---------

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:40:19 +07:00
Mohamed BoudraandKino Roy 27d6b3b7ce feat(desktop): remember project editors and support Xcode (#7898)
Keep editor choices local to each project and shared across its worktrees. Reuse the Xcode target contribution while preserving explicit editor selection.

Co-authored-by: Kino Roy <kinoroy@me.com>
2026-10-26 13:36:00 +07:00
Mohamed Boudra 8e5f536766 Release idle agent runtimes with work guards and live host controls (#7782)
* feat(server): release idle agent runtimes with provider safety checks

Require provider permission before releasing resumable runtimes so foreground idleness does not discard tracked background work. Preserve the saved conversation and serialize prompt admission with runtime closure; allow hosts to disable collection.

* docs: translate idle runtime cleanup configuration and provider contract

* feat(server): collect runtimes after one idle hour with optional provider vetoes

Keep callers resident while local or remote finish notification intent remains outstanding. Daemon-owned work determines baseline eligibility; provider checks add optional vetoes. Own the idle clock and sweep lifecycle in one cleanup module while the manager retains guarded closure.

* feat(agents): control idle runtime release live from host settings

Let hosts retain runtimes without restarting the daemon. Disabling invalidates pending collection decisions while preserving idle clocks for re-enabling. ACP-owned terminals and pending adapter work veto collection until they finish.

* fix(settings): clarify idle agent release with an info tooltip
2026-10-26 12:38:15 +07:00
6bc4b73c42 fix(app): keep the sidebar alive when a workspace entry has not hydrated (#7896)
ProjectBlock asserted that every placement in a project has an entry in
workspaceEntriesByKey. A retained sidebar that is inactive (compact layout
with the sidebar closed) or still waiting for sessions keeps an empty map,
and Show background stops the project list from being pruned to hydrated
entries. From the eighth workspace on, selectSidebarGroupVisibility asked
for the status of a missing entry and the app crashed on start.

Count a missing entry as Done. It renders no row, so it should not claim a
slot past the limit.

Closes #7869

Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:36:51 +07:00
Mohamed Boudra 631ed0408d fix(app): preserve host selection across settings navigation (#7893)
Own the selected host for the Settings visit so screen replacements and Back do not reset an explicit picker choice. Apply named-host entry intents once while preserving workspace defaults for new visits.
2026-10-26 11:30:54 +07:00
Mohamed Boudra 6521739947 fix(markdown): preserve inline code formatting and heading sizes (#7889)
Scale inline code with the enclosing block using theme tokens, and preserve its size and font through bold and links. Use contextual package patches so clean installs reproduce the verified native source.
2026-10-26 11:22:07 +07:00
Mohamed Boudra 6f07b0de48 feat(app): recall sent messages with Up and Down in an empty composer (#7710)
Fixing a prompt after interrupting it, or retrying one after a provider
error, meant scrolling up and copying it out of the chat. Up in an empty
agent composer now fills the last message sent to that agent; Up walks
older, Down walks newer and past the newest back to empty.

History is read from the loaded timeline on key press, not kept in a
separate store, so it follows rewinds, reloads and other devices and costs
nothing while a turn streams. Recall only starts from an empty composer, so
it never discards a half-written draft. While a recalled message is
unedited the autocomplete stays hidden, otherwise a recalled `/command`
would open the picker and Enter would pick an option instead of sending.
2026-10-26 11:18:47 +07:00
Alessio Re 16fe07560f fix(app): make question cards link-aware and selectable without accidental answers
Render question bodies and option text with the shared Markdown renderer. Opening links or selecting question and description text leaves answers unchanged. Labels remain answer controls, and double-clicking a label advances only once.

Add browser regression coverage and stabilize Markdown WASM loading in the test harness.

Closes #3164.

Co-authored-by: Alessio Re <NoSync@users.noreply.github.com>
2026-10-26 10:51:44 +07:00
Mohamed Boudra 2a890208ce feat(app): show a subagent's parent and a way back to it (#7784)
A subagent pane now names its parent and takes you back to it. With a composer, a pill leads the rail above it; without one, a parent box takes the composer's place. Both open the parent through navigateToAgent.
2026-10-26 10:51:19 +07:00
4bbacfed46 feat(app): fold finished agent turns behind a summary line (#7785)
Long agent turns bury the answer under intermediate messages, thoughts and
tool calls. A new Chat display preference (Full / Collapse tool calls /
Collapse turns) replaces the tool call detail level. Collapse turns folds
everything a finished turn did before its final message into one summary
line under the prompt; opening it shows those rows inline behind the same
indent rail that expanded tool call groups now use.

Expanded turns stay real timeline rows so virtualization, chat find and
scroll-to-message keep working; find and outline jumps open the turn that
holds their target. Opening or closing a turn stops following output so the
summary stays under the pointer.

Collapse turns is the default for new installs only. Existing settings were
materialized on first launch, so stored blobs without the new field map from
the legacy tool call detail level and otherwise stay on Full.

Co-authored-by: Ready <127541578+akaready@users.noreply.github.com>
Co-authored-by: Waishnav <86405648+Waishnav@users.noreply.github.com>
Co-authored-by: Shreyan Dey <90114886+Dey11@users.noreply.github.com>
Co-authored-by: Sebastian Parfitt <7753735+Sebeb@users.noreply.github.com>
2026-10-26 10:50:16 +07:00
Mohamed Boudra 8c9499dac6 fix(app): dismiss file link tooltips when leaving their panel (#7842)
Retained chats suspend while their tooltip portals remain mounted. Remove the surface before suspension and clear open state and pending hover timers so returning to the chat does not revive it.
2026-10-26 10:48:07 +07:00
paseo-bot[bot] d0fd2e5320 fix(client): close failed SSH tunnels without crashing callers (#7876)
Route SSH stdin stream errors through the existing tunnel failure handler so pending writes cannot emit an unhandled EPIPE. Reproduce the peer abort using real OpenSSH in an isolated caller process.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:50:06 +00:00
paseo-bot[bot]androyenheart 5dc350034e Keep large Import Session lists responsive on Web (#7872)
* Virtualize long Import Session Web lists while preserving native and short-list scrolling

* fix(app): measure import list headers through the existing layout event

Keep the virtualized import list usable in the existing component harness without introducing an observer mock. Import React for the unit JSX transform.

Co-authored-by: royenheart <royenheart@outlook.com>

---------

Co-authored-by: royenheart <royenheart@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:05:12 +00:00
4845e1da58 fix(app): link bare URLs with ports, colons and user info as one link (#7859)
MD4C's permissive URL autolink read the host, never a ":port", and
accepted ":" as a link boundary. "http://127.0.0.1:8059/?token=abc"
linked only "http://127.0.0.1". A colon in the path ended the link the
same way.

Port the upstream md4c fix into the dependency patch:
- 3da1937: accept single-component hosts and a numeric port after an
  explicit scheme
- f895c2b: scan the URL tail continuously and trim terminal
  punctuation and excess closing parentheses
- 1645761: refuse an empty hostname
- f9e5eb2: stop the tail rescan from going quadratic on long lines

Also refuse the autolink when a colon after the host continues the URL
("user:pass@host"). Upstream does not parse user info, and the
single-component host rule would link only "http://user". The
text-link pattern links these URLs whole.

Rebuild the web parser with emsdk 4.0.10, which reproduces the
existing glue. That version has no GROWABLE_ARRAYBUFFERS setting, and
the existing artifact was built without it.

The fallback text-link pattern handles URLs MD4C rejects, like
user@host and IPv6 hosts. It now stops at quotes and unbalanced
parentheses and leaves trailing sentence punctuation outside the link.

Closes #7440

Co-authored-by: Mariotaku <git@mariotaku.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:01:31 +00:00
paseo-bot[bot]andDavin 72e3316f4f fix(opencode): show model details on v2 subagent rows (#7799)
The v2 child descriptor omitted the session's presentation facts, leaving
completed children with title-only rows. Reuse the existing subtitle
formatter for both live updates and reconciliation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>
2026-10-24 13:58:06 +00:00
Okan YILDIRIM 566218d682 fix(claude): avoid probing file targets during history replay (#7789) 2026-10-24 11:58:30 +00:00
Okan YILDIRIM b8bb335c56 fix(app): close comboboxes when their retained panel becomes inactive (#7783)
Close open comboboxes through their existing handler and remove their
presentation before a retained chat can freeze its closing update.

The compact regression came from unmounting the sheet while its visibility
tracker survived in Combobox. Its non-closed phase blocked the next present().
Keep the tracker inside the sheet component so they unmount together, and
preserve the desktop closed-state guard in Combobox.

Cover draft and existing-chat model selectors and the compact tab switcher
through Settings, Back, and reopening. The desktop regressions also verify
Escape dismissal.
2026-10-24 07:25:30 +00:00
paseo-ai[bot] e01a38d943 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-24 06:13:55 +00:00
749 changed files with 52264 additions and 12070 deletions
+19
View File
@@ -0,0 +1,19 @@
---
name: store-screenshots
description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed.
user-invocable: true
---
# Store screenshots
Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports.
Working rules:
- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store.
- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type.
- Use SF Pro. Never substitute another font.
- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview.
- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks.
- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons.
- When you learn something the next capture needs, fix the README rather than this skill.
+11 -18
View File
@@ -133,11 +133,12 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -395,7 +396,7 @@ jobs:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -417,19 +418,11 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
@@ -485,7 +478,7 @@ jobs:
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
@@ -495,7 +488,7 @@ jobs:
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
@@ -505,7 +498,7 @@ jobs:
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
@@ -515,7 +508,7 @@ jobs:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
@@ -525,7 +518,7 @@ jobs:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
@@ -535,7 +528,7 @@ jobs:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
@@ -545,7 +538,7 @@ jobs:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
+28
View File
@@ -169,6 +169,34 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+170
View File
@@ -85,6 +85,176 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
{
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
"rules": {
"paseo/no-sync-io": [
"error",
{
"allow": {
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
"executable-resolution/executable-resolution.ts": ["existsSync"],
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
"executable-resolution/windows.ts": ["readdirSync"],
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
"server/agent/providers/claude/agent.ts": ["existsSync"],
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
"server/agent/providers/claude/subagents/workflow-output.ts": [
"closeSync",
"openSync",
"readSync"
],
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
"server/agent/providers/omp/agent.ts": ["existsSync"],
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
"server/agent/providers/pi/agent.ts": [
"existsSync",
"mkdtempSync",
"readFileSync",
"rmSync",
"writeFileSync"
],
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
"server/config.ts": ["existsSync"],
// Startup identity: load or create the daemon keypair before accepting connections.
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
"server/host-name.ts": ["execFileSync"],
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
"server/hub/relationship-controller.ts": [
"existsSync",
"readFileSync",
"renameSync",
"rmSync"
],
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
"server/local-credential.ts": ["readFileSync"],
// Startup logger construction: create the log directory before opening the logging destination.
"server/logger.ts": ["mkdirSync"],
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
// Module initialization: the package version is resolved once and cached at import.
"server/package-version.ts": ["existsSync", "readFileSync"],
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
"server/persisted-config.ts": ["existsSync", "readFileSync"],
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
"server/pid-lock.ts": ["readFileSync"],
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
"server/plugins/builtin/index.ts": ["existsSync"],
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
// Build child process: select the esbuild binary for the isolated plugin compiler.
"server/plugins/compiler-esbuild.ts": ["existsSync"],
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
"server/private-files.ts": [
"chmodSync",
"mkdirSync",
"realpathSync",
"renameSync",
"rmSync",
"writeFileSync"
],
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
"server/push/token-store.ts": ["existsSync", "readFileSync"],
// Startup identity: load or create the server id before accepting connections.
"server/server-id.ts": ["existsSync", "readFileSync"],
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
// Speech child process: native addon discovery and patchelf run outside the daemon.
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
"existsSync",
"spawnSync"
],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
"server/web-ui.ts": ["readFileSync", "statSync"],
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
"server/workspace-registry-bootstrap.ts": ["statSync"],
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
"terminal/agent-hooks/agent-hook-installer.ts": [
"existsSync",
"readFileSync",
"rmSync"
],
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
"terminal/terminal-worker-process.ts": [
"chmodSync",
"closeSync",
"existsSync",
"openSync",
"rmSync",
"writeSync"
],
// Worker startup only: choose and secure the local listening socket before accepting a controller.
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
"terminal/terminal-worker-runtime.ts": [
"chmodSync",
"existsSync",
"mkdirSync",
"readdirSync",
"readFileSync",
"renameSync",
"rmSync",
"statSync",
"writeFileSync"
],
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
"terminal/paseo-cli-path.ts": ["existsSync"],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
"terminal/terminal.ts": [
"accessSync",
"chmodSync",
"existsSync",
"mkdirSync",
"readFileSync",
"statSync"
],
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
"utils/path.ts": ["realpathSync"]
}
}
]
}
},
{
"files": [
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"packages/server/src/**/test-utils/**",
"packages/server/src/server/daemon-e2e/**",
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
],
"rules": { "paseo/no-sync-io": "off" }
},
{
"files": [
"plugins/*-provider/server/**/*.ts",
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
### Added
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### Improved
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+77
View File
@@ -1,5 +1,82 @@
# 更新日志
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### 改进
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
+1
View File
@@ -66,6 +66,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
+1
View File
@@ -0,0 +1 @@
output/preview.png
+98
View File
@@ -0,0 +1,98 @@
# Store screenshots
Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change.
| # | Headline | Capture | Phone callout | Tablet callout |
| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) |
| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) |
| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover |
| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) |
| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree |
On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`.
## Creative direction
The panels follow paseo.sh:
- **Canvas:** the site's `#101615` background.
- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines.
- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height.
- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site.
- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred.
- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain.
Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups.
Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it.
## Files
- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts.
- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout.
- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there.
- `demo/`: the staged demo host the captures come from.
- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot.
- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures.
- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot.
- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures.
- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store.
All exports are opaque RGB PNGs.
## Render
```sh
SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs
```
`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font.
Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased.
Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing.
Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it.
## Recapture
1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree:
```sh
npx tsx assets/store-screenshots/demo/stage.mts
```
Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output.
2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs.
```sh
EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port <port>
ssh -o ExitOnForwardFailure=yes -R <port>:127.0.0.1:<port> -R 7791:127.0.0.1:7791 <mac-host>
```
3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:<port>`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set.
- Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar <device> override`).
- On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays.
- Name the host "MacBook Pro" and set the sidebar badge to Hidden.
- Group the sidebar by status.
- If you reset the simulator, reinstall the dev client.
4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture.
5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store:
```sh
agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868
agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752
```
- **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat.
- **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks.
- **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 <terminal-id> "<text>" Enter` instead of the simulator keyboard, then dismiss the software keyboard.
- **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`.
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
@@ -0,0 +1,84 @@
// Applies the demo change to a checkout of the Paseo repository.
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = process.argv[2];
const client = path.join(repo, "packages/client/src");
copyFileSync(
path.join(here, "reconnect-delay.ts"),
path.join(client, "connection/reconnect-delay.ts"),
);
copyFileSync(
path.join(here, "reconnect-delay.test.ts"),
path.join(client, "connection/reconnect-delay.test.ts"),
);
function edit(file, pairs) {
let text = readFileSync(file, "utf8");
for (const [from, to] of pairs) {
if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`);
text = text.replace(from, to);
}
writeFileSync(file, text);
}
edit(path.join(client, "daemon-client.ts"), [
[
`import { CreationClient } from "./creation/index.js";\n`,
`import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`,
],
[
` private reconnectAttempt = 0;\n`,
` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`,
],
[
` nextRetryAt: new Date(
Date.now() +
Math.min(
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** this.reconnectAttempt,
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
),
).toISOString(),
`,
` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(),
`,
],
[
` private armReconnectTimer(): void {
const attempt = this.reconnectAttempt;
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
`,
` // The countdown and the timer must agree, so each attempt draws its jitter once.
private getNextReconnectDelayMs(): number {
const attempt = this.reconnectAttempt;
if (this.nextReconnectDelay?.attempt !== attempt) {
this.nextReconnectDelay = {
attempt,
delayMs: getReconnectDelayMs(attempt, {
baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
}),
};
}
return this.nextReconnectDelay.delayMs;
}
private armReconnectTimer(): void {
const attempt = this.reconnectAttempt;
const delay = this.getNextReconnectDelayMs();
`,
],
]);
edit(path.join(repo, "CHANGELOG.md"), [
[
`# Changelog\n\n`,
`# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`,
],
]);
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { getReconnectDelayMs } from "./reconnect-delay";
const options = { baseDelayMs: 1500, maxDelayMs: 30_000 };
describe("getReconnectDelayMs", () => {
it("doubles the ceiling on each attempt", () => {
const top = () => 1;
expect(getReconnectDelayMs(0, options, top)).toBe(1500);
expect(getReconnectDelayMs(1, options, top)).toBe(3000);
expect(getReconnectDelayMs(2, options, top)).toBe(6000);
});
it("never waits longer than the max delay", () => {
expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000);
});
it("spreads retries between half and the full ceiling", () => {
expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000);
expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000);
expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000);
});
it("keeps the first retry under two seconds", () => {
for (let i = 0; i < 100; i += 1) {
expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500);
}
});
});
@@ -0,0 +1,18 @@
export interface ReconnectDelayOptions {
baseDelayMs: number;
maxDelayMs: number;
}
/**
* Exponential backoff with equal jitter. Half of each delay is fixed and half
* is random, so a relay restart spreads reconnects out instead of every client
* retrying on the same tick.
*/
export function getReconnectDelayMs(
attempt: number,
options: ReconnectDelayOptions,
random: () => number = Math.random,
): number {
const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs);
return Math.round(ceiling / 2 + random() * (ceiling / 2));
}
@@ -0,0 +1,204 @@
// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on
// every composer that opens the latest preview deploy for each branch. The
// deploys are fixed sample data; the UI is a real plugin surface.
import type {
PluginButtonContentProps,
PluginButtonRegistration,
PluginClientContext,
} from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";
type DeployStatus = "ready" | "building" | "failed";
interface Deploy {
branch: string;
status: DeployStatus;
detail: string;
age: string;
progress?: number;
}
const DEPLOYS: Deploy[] = [
{ branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" },
{
branch: "perf/provider-snapshot-cache",
status: "building",
detail: "Bundling app",
age: "now",
progress: 0.64,
},
{ branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" },
{
branch: "i18n/ko-settings",
status: "failed",
detail: "Missing key settings.notifications",
age: "26m",
},
];
function DeploysSheet({ theme, layout }: PluginButtonContentProps) {
const mono = layout.platform === "ios" ? "Menlo" : "monospace";
const styles = useMemo(
() => ({
// Popovers on tablets and desktop are narrow by default; give the
// branch names room. Phones show the sheet at full width.
body: { gap: 14, width: layout.compact ? undefined : 400 },
production: {
padding: 14,
gap: 6,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
},
productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
label: { color: theme.colors.foregroundMuted, fontSize: 13 },
version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const },
live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const },
heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 },
list: {
borderRadius: 12,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden" as const,
},
row: {
flexDirection: "row" as const,
alignItems: "center" as const,
gap: 12,
paddingVertical: 12,
paddingHorizontal: 14,
},
divider: { height: 1, backgroundColor: theme.colors.border },
text: { flex: 1, gap: 4 },
branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono },
detail: { color: theme.colors.foregroundMuted, fontSize: 13 },
failed: { color: theme.colors.statusDanger, fontSize: 13 },
age: { color: theme.colors.foregroundMuted, fontSize: 13 },
track: {
height: 4,
marginTop: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface2,
overflow: "hidden" as const,
},
}),
[theme, mono, layout.compact],
);
const dotColor: Record<DeployStatus, string> = {
ready: theme.colors.statusSuccess,
building: theme.colors.statusWarning,
failed: theme.colors.statusDanger,
};
return (
<View style={styles.body}>
<View style={styles.production}>
<View style={styles.productionTop}>
<Dot color={theme.colors.statusSuccess} />
<Text style={styles.live}>Live</Text>
<Text style={styles.label}>Production · main</Text>
</View>
<Text style={styles.version}>v0.14.0-beta.1</Text>
<Text style={styles.label}>Deployed 3h ago</Text>
</View>
<Text style={styles.heading}>Previews</Text>
<View style={styles.list}>
{DEPLOYS.map((deploy, index) => (
<View key={deploy.branch}>
{index > 0 ? <View style={styles.divider} /> : null}
<View style={styles.row}>
<Dot color={dotColor[deploy.status]} />
<View style={styles.text}>
<Text style={styles.branch} numberOfLines={1}>
{deploy.branch}
</Text>
<Text style={deploy.status === "failed" ? styles.failed : styles.detail}>
{deploy.detail}
</Text>
{deploy.progress !== undefined ? (
<Progress
value={deploy.progress}
track={styles.track}
color={theme.colors.statusWarning}
/>
) : null}
</View>
<Text style={styles.age}>{deploy.age}</Text>
</View>
</View>
))}
</View>
</View>
);
}
function Progress({ value, track, color }: { value: number; track: object; color: string }) {
const fill = useMemo(
() => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }),
[value, color],
);
return (
<View style={track}>
<View style={fill} />
</View>
);
}
function Dot({ color }: { color: string }) {
const style = useMemo(
() => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }),
[color],
);
return <View style={style} />;
}
export default function contribute(client: PluginClientContext) {
const pills = new Map<string, PluginButtonRegistration>();
const lifetime = new AbortController();
const register = (agent: { id: string; workspaceId?: string | null }) => {
if (lifetime.signal.aborted || !agent.workspaceId) return;
pills.get(agent.id)?.remove();
pills.set(
agent.id,
client.addComposerPill({
id: "deploys",
workspaceId: agent.workspaceId,
agentId: agent.id,
button: {
title: "Preview deploys",
icon: "Rocket",
label: "Deploys",
behavior: { kind: "popover", Content: DeploysSheet },
},
}),
);
};
void client.paseo.agents
.list({ subscribe: {}, signal: lifetime.signal })
.then(({ subscription }) => {
subscription.subscribe({
snapshot: ({ entries }) => {
for (const pill of pills.values()) pill.remove();
pills.clear();
for (const { agent } of entries) register(agent);
},
update: (message) => {
if (message.type !== "agent_update") return;
const update = message.payload;
if (update.kind === "remove") {
pills.get(update.agentId)?.remove();
pills.delete(update.agentId);
} else register(update.agent);
},
});
return undefined;
})
.catch((error) => {
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
});
return () => {
lifetime.abort();
for (const pill of pills.values()) pill.remove();
pills.clear();
};
}
@@ -0,0 +1,4 @@
{
"id": "preview-deploys",
"requirements": { "paseo": ">=0.8.0" }
}
@@ -0,0 +1,281 @@
// Scripted Claude Code and Codex clients for staging store screenshots.
// Each agent replays a fixed conversation through the real daemon, so the app
// renders real timeline rows, permission prompts, and running states.
import { randomUUID } from "node:crypto";
import type {
AgentCapabilityFlags,
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
ToolCallDetail,
} from "../../../packages/server/src/server/agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export type ScriptStep =
| { kind: "say"; text: string }
| { kind: "think"; text: string }
| { kind: "tool"; name: string; detail: ToolCallDetail }
| { kind: "todo"; items: { text: string; completed: boolean }[] }
| { kind: "wait"; ms: number }
| { kind: "running-tool"; name: string; detail: ToolCallDetail }
| { kind: "ask"; name: string; title: string; detail: ToolCallDetail };
export interface AgentScript {
/** Matched against the start of the prompt that opens the turn. */
prompt: string;
steps: ScriptStep[];
/** Leave the turn open after the last step. */
keepRunning?: boolean;
/** Pause between steps, so "Worked for" reflects a believable duration. */
stepDelayMs?: number;
}
const CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const MODELS: Record<string, AgentModelDefinition[]> = {
claude: [
{
provider: "claude",
id: "claude-opus-5-5",
label: "Opus 5.5",
isDefault: true,
thinkingOptions: [
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "medium",
},
{ provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" },
],
codex: [
{
provider: "codex",
id: "gpt-5.6-sol",
label: "GPT-5.6-Sol",
isDefault: true,
thinkingOptions: [
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "medium",
},
],
};
function promptText(prompt: AgentPromptInput): string {
if (typeof prompt === "string") return prompt;
return prompt
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
.join("");
}
class ScriptedSession implements AgentSession {
readonly capabilities = CAPABILITIES;
readonly id = randomUUID();
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history: AgentStreamEvent[] = [];
private pending: AgentPermissionRequest[] = [];
private activeTurnId: string | null = null;
constructor(
readonly provider: string,
private readonly config: AgentSessionConfig,
private readonly scripts: AgentScript[],
) {}
private emit(event: AgentStreamEvent): void {
const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event;
this.history.push(tagged);
for (const listener of this.listeners) listener(tagged);
}
private timeline(item: AgentTimelineItem): void {
this.emit({ type: "timeline", provider: this.provider, item });
}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
const turnId = randomUUID();
this.activeTurnId = turnId;
const text = promptText(prompt);
const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt));
setTimeout(() => void this.play(text, script), 50);
return { turnId };
}
private async play(text: string, script: AgentScript | undefined): Promise<void> {
this.emit({ type: "turn_started", provider: this.provider });
this.timeline({ type: "user_message", text, messageId: randomUUID() });
const delay = script?.stepDelayMs ?? 400;
for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) {
await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay));
this.playStep(step);
if (step.kind === "ask") return;
}
if (script?.keepRunning) return;
this.emit({
type: "turn_completed",
provider: this.provider,
usage: { inputTokens: 48_210, outputTokens: 3_904 },
});
this.activeTurnId = null;
}
private playStep(step: ScriptStep): void {
switch (step.kind) {
case "say":
this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() });
return;
case "think":
this.timeline({ type: "reasoning", text: step.text });
return;
case "todo":
this.timeline({ type: "todo", items: step.items });
return;
case "wait":
return;
case "tool":
case "running-tool": {
const callId = randomUUID();
this.timeline({
type: "tool_call",
callId,
name: step.name,
status: "running",
error: null,
detail: step.detail,
});
if (step.kind === "tool") {
this.timeline({
type: "tool_call",
callId,
name: step.name,
status: "completed",
error: null,
detail: step.detail,
});
}
return;
}
case "ask": {
const request: AgentPermissionRequest = {
id: randomUUID(),
provider: this.provider,
name: step.name,
kind: "tool",
title: step.title,
detail: step.detail,
};
this.pending = [request];
this.emit({ type: "permission_requested", provider: this.provider, request });
}
}
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* this.history;
}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null,
thinkingOptionId: this.config.thinkingOptionId ?? "medium",
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return getAgentProviderDefinition(this.provider).modes;
}
async getCurrentMode(): Promise<string | null> {
return this.config.modeId ?? null;
}
async setMode(): Promise<void> {}
getPendingPermissions(): AgentPermissionRequest[] {
return this.pending;
}
async respondToPermission(requestId: string, resolution: AgentPermissionResponse) {
this.pending = [];
this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution });
}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {
if (!this.activeTurnId) return;
this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" });
this.activeTurnId = null;
}
async close(): Promise<void> {}
}
export class ScriptedAgentClient implements AgentClient {
readonly capabilities = CAPABILITIES;
constructor(
readonly provider: string,
private readonly scripts: AgentScript[],
) {}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ScriptedSession(this.provider, config, this.scripts);
}
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new ScriptedSession(
this.provider,
{ provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides },
this.scripts,
);
}
async fetchCatalog() {
return {
models: MODELS[this.provider] ?? [],
modes: getAgentProviderDefinition(this.provider).modes,
};
}
async isAvailable(): Promise<boolean> {
return true;
}
}
+556
View File
@@ -0,0 +1,556 @@
// Stages the demo host used for store screenshots.
//
// npx tsx assets/store-screenshots/demo/stage.mts
//
// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude
// Code and Codex clients, adds a local clone of this repository as the `paseo`
// project, cuts real worktrees, applies a real change to the hero worktree, and
// opens a terminal there. Leave it running while you capture.
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js";
import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js";
import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../..");
const demoRoot = path.join(repoRoot, ".dev/store-capture/demo");
const codeRoot = path.join(demoRoot, "code");
const pluginDir = path.join(here, "preview-deploys");
const port = Number(process.env.DEMO_PORT ?? 7791);
const minute = 60_000;
const claudeScripts: AgentScript[] = [];
const codexScripts: AgentScript[] = [];
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], {
cwd,
encoding: "utf8",
});
}
function run(cwd: string, command: string): string {
return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
}
async function prepareRepos(): Promise<void> {
// A fresh clone each run: worktrees from an earlier run stay registered in it.
// Worktree setup (npm ci, builds) is dropped so new worktrees open instantly.
const paseo = path.join(codeRoot, "paseo");
await rm(paseo, { recursive: true, force: true });
await mkdir(codeRoot, { recursive: true });
execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]);
git(paseo, "checkout", "-q", "-B", "main");
git(paseo, "remote", "remove", "origin");
const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8"));
delete config.worktree;
await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
git(paseo, "commit", "-qam", "chore: skip worktree setup in demo");
const dotfiles = path.join(codeRoot, "dotfiles");
if (!existsSync(dotfiles)) {
await mkdir(dotfiles, { recursive: true });
await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n");
await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n");
git(dotfiles, "init", "-q", "-b", "main");
git(dotfiles, "add", ".");
git(dotfiles, "commit", "-qm", "Initial dotfiles");
}
}
async function main(): Promise<void> {
await prepareRepos();
const home = path.join(demoRoot, "home");
await rm(home, { recursive: true, force: true });
await mkdir(home, { recursive: true });
const daemon = await createTestPaseoDaemon({
agentClients: {
claude: new ScriptedAgentClient("claude", claudeScripts),
codex: new ScriptedAgentClient("codex", codexScripts),
},
listen: "127.0.0.1",
listenPort: port,
paseoHomeRoot: home,
mcpEnabled: false,
cleanup: false,
pluginsEnabled: true,
plugins: {
"preview-deploys": { source: "directory", path: pluginDir, enabled: true },
},
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0",
});
await client.connect();
await client.fetchAgents({ subscribe: {} });
const paseo = await client.addProject(path.join(codeRoot, "paseo"));
const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles"));
const paseoProjectId = paseo.project!.projectId;
async function worktree(title: string, branchName: string) {
const result = await client.createWorkspace({
title,
source: {
kind: "worktree",
projectId: paseoProjectId,
action: "branch-off",
baseBranch: "main",
branchName,
},
});
if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`);
return result.workspace;
}
async function agent(input: {
provider: "claude" | "codex";
workspace: { id: string; workspaceDirectory?: string | null };
cwd: string;
title: string;
script: AgentScript;
}) {
(input.provider === "claude" ? claudeScripts : codexScripts).push(input.script);
return client.createAgent({
provider: input.provider,
cwd: input.cwd,
workspaceId: input.workspace.id,
title: input.title,
initialPrompt: input.script.prompt,
});
}
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Done: finished earlier and already read.
const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1");
const releaseAgent = await agent({
provider: "codex",
workspace: release,
cwd: release.workspaceDirectory!,
title: "Release v0.14.0-beta.1",
script: {
prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.",
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: "npm run release:beta",
output: "Tagged v0.14.0-beta.1\n",
exitCode: 0,
},
},
{
kind: "say",
text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.",
},
],
},
});
const dotfilesWorkspace = (
await client.createWorkspace({
title: "Add a tmux status line",
source: {
kind: "directory",
path: path.join(codeRoot, "dotfiles"),
projectId: dotfiles.project!.projectId,
},
})
).workspace!;
const tmuxAgent = await agent({
provider: "claude",
workspace: dotfilesWorkspace,
cwd: path.join(codeRoot, "dotfiles"),
title: "Add a tmux status line",
script: {
prompt: "Add a minimal tmux status line with the session name and battery.",
steps: [
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "tmux.conf",
oldString: "",
newString: "set -g status-right '#S · #{battery_percentage}'",
},
},
{
kind: "say",
text: "Added a status line with the session name on the left and battery on the right.",
},
],
},
});
await pause(3000);
await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]);
// Working: turns that stay open while you capture.
const ipad = await worktree(
"Keep the composer above the iPad keyboard",
"fix/ipad-keyboard-inset",
);
await agent({
provider: "codex",
workspace: ipad,
cwd: ipad.workspaceDirectory!,
title: "Keep the composer above the iPad keyboard",
script: {
prompt:
"On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.",
keepRunning: true,
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: 'rg -n "keyboardHeight" packages/app/src',
output:
"packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n",
exitCode: 0,
},
},
{
kind: "tool",
name: "read",
detail: {
type: "read",
filePath: "packages/app/src/keyboard/shift/internal/provider.ts",
},
},
{
kind: "say",
text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.",
},
{
kind: "running-tool",
name: "shell",
detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" },
},
],
},
});
const snapshots = await worktree(
"Cache provider snapshots on disk",
"perf/provider-snapshot-cache",
);
await agent({
provider: "claude",
workspace: snapshots,
cwd: snapshots.workspaceDirectory!,
title: "Cache provider snapshots on disk",
script: {
prompt:
"Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.",
keepRunning: true,
steps: [
{
kind: "todo",
items: [
{ text: "Find where snapshots are built", completed: true },
{ text: "Persist the last snapshot per host", completed: false },
{ text: "Refresh in the background after boot", completed: false },
],
},
{
kind: "tool",
name: "Read",
detail: {
type: "read",
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
},
},
{
kind: "running-tool",
name: "Edit",
detail: {
type: "edit",
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
},
},
],
},
});
// Needs input: waiting on a permission.
const korean = await worktree("Korean translations for Settings", "i18n/ko-settings");
await agent({
provider: "claude",
workspace: korean,
cwd: korean.workspaceDirectory!,
title: "Korean translations for Settings",
script: {
prompt: "Translate the new Settings strings into Korean and open a PR.",
steps: [
{
kind: "tool",
name: "Read",
detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" },
},
{
kind: "tool",
name: "Edit",
detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" },
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "npm run test --workspace=@getpaseo/app -- i18n",
output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n",
exitCode: 0,
},
},
{ kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." },
{
kind: "ask",
name: "Bash",
title: "Bash",
detail: { type: "shell", command: "git push -u origin i18n/ko-settings" },
},
],
},
});
// Ready to review: a second finished agent.
const terminals = await worktree(
"Remember the last terminal per workspace",
"feat/remember-terminal",
);
await agent({
provider: "codex",
workspace: terminals,
cwd: terminals.workspaceDirectory!,
title: "Remember the last terminal per workspace",
script: {
prompt:
"When I switch back to a workspace, open the terminal I was using last instead of the first one.",
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: 'rg -n "activeTerminalId" packages/app/src',
output: "",
exitCode: 0,
},
},
{
kind: "say",
text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.",
},
],
},
});
// Plugin: the agent "built" the preview-deploys plugin this daemon runs.
const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys");
const pluginSource = (file: string) => run(pluginDir, `cat ${file}`);
await agent({
provider: "claude",
workspace: plugin,
cwd: plugin.workspaceDirectory!,
title: "Build a preview deploys plugin",
script: {
prompt: "Build me a plugin that shows our preview deploys right next to the composer.",
stepDelayMs: 6_000,
steps: [
{ kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } },
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "plugins/preview-deploys/paseo-plugin.json",
content: pluginSource("paseo-plugin.json"),
},
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "plugins/preview-deploys/index.client.tsx",
content: pluginSource("index.client.tsx"),
},
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "paseo plugin install ./plugins/preview-deploys",
output: "Installed preview-deploys\n",
exitCode: 0,
},
},
{
kind: "say",
text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.",
},
],
},
});
// Hero: Claude finished a real change in a real worktree.
const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter");
const cwd = jitter.workspaceDirectory!;
await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules"));
execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]);
const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts");
const vitest = run(
cwd,
"npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
).replace(/^ RUN .*\n/m, "");
const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts");
await agent({
provider: "claude",
workspace: jitter,
cwd,
title: "Add jitter to reconnect backoff",
script: {
prompt:
'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.',
stepDelayMs: 7_000,
steps: [
{
kind: "tool",
name: "Grep",
detail: {
type: "search",
toolName: "grep",
query: "reconnectAttempt",
filePaths: ["packages/client/src/daemon-client.ts"],
numFiles: 1,
numMatches: 9,
mode: "files_with_matches",
},
},
{
kind: "tool",
name: "Read",
detail: {
type: "read",
filePath: "packages/client/src/daemon-client.ts",
offset: 7040,
limit: 60,
content: readExcerpt,
},
},
{
kind: "say",
text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.",
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "packages/client/src/connection/reconnect-delay.ts",
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"),
},
},
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "packages/client/src/daemon-client.ts",
unifiedDiff: clientDiff,
},
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "packages/client/src/connection/reconnect-delay.test.ts",
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"),
},
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
output: vitest,
exitCode: 0,
},
},
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "CHANGELOG.md",
unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"),
},
},
{
kind: "say",
text: [
"Reconnects now back off with jitter.",
"",
"- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.",
'- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.',
"- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.",
"",
"Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.",
].join("\n"),
},
],
},
});
const terminal = await client.createTerminal(cwd, "zsh", undefined, {
workspaceId: jitter.id,
command: "bash",
args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"],
});
const terminalId = terminal.terminal?.id;
if (terminalId) {
await pause(1500);
for (const line of [
"git status --short",
"cd packages/client",
"npx vitest run reconnect --reporter=tree",
]) {
client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` });
await pause(4000);
}
}
console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`);
await writeFile(
path.join(demoRoot, "state.json"),
JSON.stringify(
{ port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } },
null,
2,
),
);
const stop = async () => {
await client.close();
await daemon.close();
process.exit(0);
};
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
void minute;
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,19 @@
# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a
# phone-width terminal, and vitest output without the absolute checkout path
# or the long timing breakdown. Vitest drops its colors when piped, so the
# wrapper puts its usual green checks and grey timings back.
export FORCE_COLOR=1
__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; }
__branch() { git branch --show-current 2>/dev/null; }
__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; }
PROMPT_COMMAND='printf "\e]0;zsh\a"'
PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] '
npx() {
command npx "$@" 2>&1 | sed -E \
-e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \
-e $'s/✓/\e[32m✓\e[0m/' \
-e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \
-e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \
-e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/'
}
clear
Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

+396
View File
@@ -0,0 +1,396 @@
// Renders the App Store and Google Play screenshots from the native captures in
// raw/. Every image is resized with Lanczos to the exact pixel size it occupies
// on the canvas, and the page renders at device scale 1, so the browser never
// resamples a capture.
//
// node assets/store-screenshots/render.mjs
//
// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts;
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import sharp from "sharp";
const root = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(root, "../..");
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
const output = path.join(root, "output");
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
const DEVICES = {
iphone: {
capture: { width: 1320, height: 2868 },
radius: 186,
bezel: 22,
island: { width: 378, height: 112, top: 33 },
},
ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null },
};
// Every title wraps to two lines and every subline fits on one; the renderer
// fails otherwise, because the device is placed from that fixed text height.
//
// Each scene names its capture per device, and a device entry may override the
// title or subline where the copy depends on the form factor.
//
// A callout lifts a region of a capture out of the device. `crop` is in native
// capture pixels, `zoom` is its size relative to the device screen (capped to
// the canvas width), and the callout is centred on the spot the crop comes
// from, moved by `shift` native pixels down the screen.
const SCENES = [
{
id: "sidebar",
backdrop: "agents",
title: "Run many coding agents at once",
body: "See which agents are working and which need you.",
iphone: { capture: "sidebar" },
ipad: { capture: "sidebar" },
},
{
id: "chat",
title: "Your full dev environment on your phone",
body: "Start agents and follow every edit and command.",
iphone: { capture: "chat" },
ipad: { capture: "chat", title: "Your full dev environment on your tablet" },
},
{
id: "plugins",
title: "Ask an agent to change the app itself",
body: "This Deploys sheet is a plugin an agent wrote.",
iphone: {
capture: "plugin",
callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 },
},
ipad: {
capture: "plugin",
body: "This Deploys panel is a plugin an agent wrote.",
callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 },
},
},
{
id: "review",
title: "Review the diff before you merge",
body: "Every file the agent changed, on its own branch.",
iphone: {
capture: "diff",
callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 },
},
ipad: { capture: "diff" },
},
{
id: "terminal",
title: "A real terminal in every workspace",
body: "Run the tests yourself, on your own machine.",
iphone: {
capture: "terminal",
callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 },
},
ipad: {
capture: "terminal",
callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 },
},
},
];
// `textTop` is where the text starts when the device is sized by the canvas
// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device
// by its width and centres the text and device instead.
const STORES = {
apple: {
device: "iphone",
width: 1320,
height: 2868,
textTop: 230,
titleSize: 100,
bodySize: 46,
textWidth: 1120,
},
google: {
device: "iphone",
width: 1440,
height: 2560,
textTop: 150,
titleSize: 96,
bodySize: 44,
textWidth: 1200,
},
"apple-ipad": {
device: "ipad",
width: 2064,
height: 2752,
textTop: 200,
titleSize: 124,
bodySize: 56,
textWidth: 1400,
},
"google-tablet": {
device: "ipad",
width: 1800,
height: 3200,
textTop: 200,
titleSize: 136,
bodySize: 60,
textWidth: 1560,
},
};
const TITLE_LINE = 1.06;
const BODY_LINE = 1.3;
const BODY_GAP = 30;
// The device starts this far below the text and runs BLEED off the bottom.
const GAP = 140;
const BLEED = 40;
const MARGIN = 56;
const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" };
function findFont() {
const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean);
for (const dir of dirs) {
const file = path.join(dir, "SFNS.ttf");
if (existsSync(file)) return file;
}
throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS.");
}
// The device fills the canvas from GAP below the text to just past the bottom
// edge, so a taller canvas gets a larger device rather than a larger gap. When
// that would be wider than the canvas, the device takes the full width and the
// text and the whole device sit centred, still GAP apart.
function deviceLayout(store, device) {
const { capture, bezel } = device;
const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE;
const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel;
const widest = store.width - 2 * MARGIN - 2 * bezel;
const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest);
const screenHeight = Math.round((screenWidth * capture.height) / capture.width);
const scale = screenWidth / capture.width;
const width = screenWidth + 2 * bezel;
const height = screenHeight + 2 * bezel;
const textTop =
screenWidth < widest
? store.height + BLEED - height - GAP - textHeight
: (store.height - height - GAP - textHeight) / 2;
const island = device.island && {
width: Math.round(device.island.width * scale),
height: Math.round(device.island.height * scale),
top: Math.round(device.island.top * scale),
};
return {
textTop: Math.round(textTop),
left: Math.round((store.width - width) / 2),
top: Math.round(textTop + textHeight + GAP),
width,
height,
bezel,
screenWidth,
screenHeight,
scale,
radius: Math.round(device.radius * scale),
island,
};
}
function calloutLayout(callout, frame, store) {
const [cropX, cropY, cropWidth, cropHeight] = callout.crop;
const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth);
const width = Math.round(cropWidth * scale);
const height = Math.round(cropHeight * scale);
const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale;
const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale;
const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN));
return {
width,
height,
left: clamp(centreX - width / 2, store.width - width),
top: clamp(centreY - height / 2, store.height - height),
};
}
async function resize(source, crop, width, height, file) {
let image = sharp(source);
if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] });
await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file);
return pathToFileURL(file).href;
}
// Agent marks scattered behind the first panel, from the homepage's featured
// agents. Each entry is [mark, centre x, centre y, size, rotation], with the
// centre as a fraction of the canvas and the size as a fraction of its width.
const AGENT_MARKS = [
["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14],
["CodexIcon", 0.89, 0.1, 0.13, 12],
["PiIcon", 0.07, 0.34, 0.1, 10],
["OmpIcon", 0.93, 0.37, 0.11, -10],
["OpenCodeIcon", 0.08, 0.6, 0.11, -8],
["CursorIcon", 0.92, 0.63, 0.1, 16],
["MuseCodeIcon", 0.07, 0.85, 0.11, 14],
["AntigravityIcon", 0.92, 0.88, 0.12, -12],
];
async function loadAgentMarks(work) {
const bundle = path.join(work, "agent-icons.mjs");
await build({
entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")],
outfile: bundle,
bundle: true,
format: "esm",
platform: "node",
jsx: "automatic",
});
const icons = await import(pathToFileURL(bundle).href);
return Object.fromEntries(
AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]),
);
}
function backdrop(store, marks) {
return AGENT_MARKS.map(([name, x, y, size, rotation]) => {
const side = Math.round(size * store.width);
const left = Math.round(x * store.width - side / 2);
const top = Math.round(y * store.height - side / 2);
return `<div class="mark" style="left:${left}px;top:${top}px;width:${side}px;height:${side}px;transform:rotate(${rotation}deg)">${marks[name]}</div>`;
}).join("");
}
function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) {
const behind = shot.backdrop === "agents" ? backdrop(store, marks) : "";
const { island } = frame;
const notch = island
? `<div class="island" style="top:${island.top}px;width:${island.width}px;height:${island.height}px;margin-left:-${island.width / 2}px;border-radius:${island.height / 2}px"></div>`
: "";
const lifted = callout
? `<img class="callout" src="${calloutImage}" style="left:${callout.left}px;top:${callout.top}px;width:${callout.width}px;height:${callout.height}px">`
: "";
return `<!doctype html>
<meta charset="utf-8">
<style>
@font-face { font-family: "SF Pro"; src: url("${pathToFileURL(font).href}"); font-weight: 1 1000; }
* { margin: 0; box-sizing: border-box; }
html, body { width: ${store.width}px; height: ${store.height}px; overflow: hidden; background: ${COLORS.canvas}; }
body { position: relative; font-family: "SF Pro"; -webkit-font-smoothing: antialiased; }
.text { position: absolute; top: ${frame.textTop}px; left: 0; right: 0; margin: 0 auto; width: ${store.textWidth}px; text-align: center; }
.mark { position: absolute; color: #fff; opacity: 0.09; filter: grayscale(1) brightness(0) invert(1); }
.mark svg { display: block; width: 100%; height: 100%; }
h1 { color: ${COLORS.title}; font-size: ${store.titleSize}px; font-weight: 600; line-height: ${TITLE_LINE}; letter-spacing: -0.022em; text-wrap: balance; }
p { margin-top: ${BODY_GAP}px; color: ${COLORS.body}; font-size: ${store.bodySize}px; font-weight: 400; line-height: ${BODY_LINE}; letter-spacing: -0.005em; text-wrap: balance; }
.device { position: absolute; left: ${frame.left}px; top: ${frame.top}px; width: ${frame.width}px; height: ${frame.height}px; padding: ${frame.bezel}px; border-radius: ${frame.radius + frame.bezel}px; background: #040505; box-shadow: 0 0 0 3px #343b39, 0 0 0 5px #161b1a, 0 60px 140px rgba(0, 0, 0, 0.55); }
.screen { position: relative; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; border-radius: ${frame.radius}px; overflow: hidden; }
.screen img { display: block; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; }
.island { position: absolute; left: 50%; background: #000; }
.callout { position: absolute; display: block; border-radius: 36px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14), 0 40px 100px rgba(0, 0, 0, 0.6); }
</style>
${behind}
<div class="text"><h1>${shot.title}</h1><p>${shot.body}</p></div>
<div class="device"><div class="screen"><img src="${screen}">${notch}</div></div>
${lifted}`;
}
async function renderStore(browser, name, store, font, marks, work) {
const dir = path.join(output, name);
await mkdir(dir, { recursive: true });
const view = await browser.newPage({
viewport: { width: store.width, height: store.height },
deviceScaleFactor: 1,
});
const device = DEVICES[store.device];
const frame = deviceLayout(store, device);
const files = [];
for (const [index, scene] of SCENES.entries()) {
const stem = `${name}-${scene.id}`;
const shot = { ...scene, ...scene[store.device] };
const screen = await resize(
raw(store.device, shot.capture),
null,
frame.screenWidth,
frame.screenHeight,
path.join(work, `${stem}-screen.png`),
);
const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null;
const calloutImage = callout
? await resize(
raw(store.device, shot.callout.from),
shot.callout.crop,
callout.width,
callout.height,
path.join(work, `${stem}-callout.png`),
)
: null;
const html = path.join(work, `${stem}.html`);
await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks }));
await view.goto(pathToFileURL(html).href);
await view.evaluate(async () => {
await document.fonts.ready;
await Promise.all(Array.from(document.images, (image) => image.decode()));
});
await expectLines(view, "h1", 2, `${stem} title`);
await expectLines(view, "p", 1, `${stem} subline`);
const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`);
await sharp(await view.screenshot())
.removeAlpha()
.png()
.toFile(file);
files.push(file);
console.log(path.relative(repo, file));
}
await view.close();
return files;
}
async function expectLines(view, selector, expected, label) {
const lines = await view.$eval(selector, (element) =>
Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)),
);
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
}
async function renderPreview(rows) {
const tile = 440;
const gap = 40;
const resized = [];
let top = gap;
let width = 0;
for (const files of rows) {
let left = gap;
let rowHeight = 0;
for (const file of files) {
const meta = await sharp(file).metadata();
const height = Math.round((meta.height * tile) / meta.width);
const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer();
resized.push({ input, left, top });
left += tile + gap;
rowHeight = Math.max(rowHeight, height);
}
width = Math.max(width, left);
top += rowHeight + gap;
}
const file = path.join(output, "preview.png");
await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } })
.composite(resized)
.png()
.toFile(file);
console.log(path.relative(repo, file));
}
const font = findFont();
const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-"));
const browser = await chromium.launch({ headless: true });
try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
rows.push(await renderStore(browser, name, store, font, marks, work));
}
await renderPreview(rows);
} finally {
await browser.close();
await rm(work, { recursive: true, force: true });
}
+49 -3
View File
@@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro
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.
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
Collection releases the process and preserves the agent record, conversation, relationships, and
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
host config to disable automatic collection. The host setting applies immediately; config-file edits
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
started finishes. Re-enabling keeps the existing idle timestamps.
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
An idle managed child can still own background work.
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
check, the baseline decides collection after the same one-hour idle period. Once protected work
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
Finish subscriptions expose caller protection through their public capability; the collector never
reads their watches or durable records.
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
after closure starts waits for release and resumes the saved session.
Provider checks follow the signals their runtimes expose:
- Claude uses its process-local background roster and pending completion notifications. A fresh
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
demonstrate support by emitting one. Unknown versions stay resident until then.
- Codex queries background terminals for loaded threads, including native children, and protects
pending provider requests. App servers without the terminal-list RPC stay resident.
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
inbox inventories. The shared server remains leased while a session vetoes collection.
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
subagent signals also protect their parent through the daemon's subagent store.
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
runtimes without either field stay resident. These signals are not an inventory of arbitrary
processes.
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
prevent collection. This does not inspect processes launched internally by the provider.
These checks do not discover arbitrary detached processes or work hidden by third-party
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
@@ -242,7 +288,7 @@ 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. 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. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. 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.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. 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. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. 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.
+1 -1
View File
@@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
### Diagnostic logs without ADB
+3
View File
@@ -66,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports.
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
+20 -41
View File
@@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
}
},
providers: {
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: SpeechSttConfig },
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
},
log: {
level, format,
@@ -353,42 +347,27 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
### Speech configuration
Set these to select OpenAI instead of local speech:
Each use site owns its provider settings: `features.dictation.stt`,
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
it regardless of provider. `providers.local.modelsDir` remains shared storage.
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
into OpenAI sites that lack their own values, using endpoint settings before shared
settings. A provider-less site becomes local; provider names are lower-cased, and
fields ignored by the selected speech provider are discarded. The next save writes only the
inline shape. A legacy key with no site to receive it is discarded with a warning
that names its path, without logging its value.
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
and report effective env sources in `envOverrides`. A site patch that changes
`provider` replaces the entire object. The same or omitted `provider` merges fields
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
options from the previous variant.
---
+10 -6
View File
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -126,6 +126,8 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
---
@@ -148,18 +150,20 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
@@ -199,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
+53
View File
@@ -273,6 +273,26 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them.
For diff layout, two benchmarks share one corpus
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
shared document model with the web and native measurers replaced by counters, so it reports how
many strings each backend would have to shape, which is the cost that survives the move to canvas
or Skia:
```bash
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
```
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
React commit time for cold and warm toggles, then scroll frame times in both modes:
```bash
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
```
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
@@ -542,6 +562,39 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## Dependency overrides and workspace tooling
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
`react-native`, …). npm does not apply those overrides to packages it places under a
workspace's own `node_modules`. When `expo-module-scripts` was declared in
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
where the overrides apply; workspace scripts still find the binary through the root
`node_modules/.bin`.
## Dependency signature audit
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
signature and, for packages published with provenance, fetches
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
for a year. An edge that cached it in the seconds before the registry attached the publish
attestation keeps serving a document with only the provenance bundle, and npm fails with:
```
npm error code EMISSINGSIGNATUREKEY
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
```
The same package's registry signature verified a moment earlier with the same keys, so the
key list is intact. The failure names one package, repeats only for that package, and depends
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+24 -13
View File
@@ -33,25 +33,36 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
editing sessions, caches, file-link resolution and supported navigation context without moving
their providers. Keep this capability at the workspace boundary; new sheet callers must not
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
forward the same Gorhom modal queue; neither creates another stack. See
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
container owns Android Back; the pane never subscribes to it.
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the
full-screen glyph beside Close, so the title keeps its width. Compact sheets use the app overlay
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
supported navigation context without moving their providers. Keep this capability at the workspace
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
See
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
+13
View File
@@ -410,6 +410,19 @@ bridges the menu's two contexts; the rest pass `null`.
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
## Gotcha 8 — Read safe-area insets where the overlay draws
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
Before you write a new one, ask:
+2 -1
View File
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -34,6 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
+23 -4
View File
@@ -52,10 +52,12 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
layout; a row with none opens its tab.
## Pages
@@ -101,6 +103,23 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
not grow, and the keyboard comes up over the field you are typing into.
## A page that fills the sheet
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
into a fill page around the same `SheetPane` the workspace's own sheets render. See
[explorer-sidebar.md](explorer-sidebar.md#sheets).
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
that opened it.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
@@ -189,7 +208,7 @@ its own.
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
rarely the height of the page before it. A fill page is the one exception.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
+6
View File
@@ -94,6 +94,12 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
`.dev/agent-device-artifacts/composer-keyboard-ios`.
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
under an eight-line draft, selection with the keyboard shown and hidden, and
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
+34 -12
View File
@@ -99,6 +99,10 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
@@ -333,11 +337,10 @@ export default function contribute(client: PluginClientContext) {
}
```
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
The contribution function must return cleanup for resources created by plugin code. Server cleanup
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
not interrupt host teardown. Client installation retention and disposal follow the public
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
@@ -382,6 +385,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -428,14 +440,16 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/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.
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
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.
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
@@ -498,6 +512,11 @@ Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
the plugin bridge transports that query through the existing provider request/event protocol.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
@@ -601,7 +620,10 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. The installation
also run after reconnect; replace your view before applying its subsequent updates. An installation
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
+2 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
Two contracts follow from it.
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
## Client capability ownership
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+69
View File
@@ -0,0 +1,69 @@
# Terminal worker
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
## Process model
```
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
exits (re-parented to init)
```
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
## Protocol versions
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
| Worker reports | Daemon does |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| its own version | adopts it |
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
- Dev (`.ts` source) launches from source and copies nothing.
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
## Desktop
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
## Known limits
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
+25 -10
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
@@ -44,7 +44,21 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
Opening or resuming an agent fetches the latest available turns with a soft target of 40
projected items. Backward pages and prompt-jump windows also include whole turns, including
all currently available rows of an active run. There is no per-turn cap: a large run can
exceed the target. Local history mounting uses the same boundaries so revealing older rows
does not change the collapse state of a turn already displayed.
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
Reading history remains available on older hosts, whose pages can still split turns. Forward
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
@@ -149,12 +163,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
range and keeps a display-only tail of complete available turns. The item target is soft;
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
@@ -238,11 +253,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
prompts, including schedule injections, remain hidden. One visible response can span several canonical
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
Every canonical run owns a visible turn, including heartbeats and agent notifications.
Notifications delivered during a run stay inside it; batched notifications share the run
that starts or accepts them. Received messages retain sender attribution, while lifecycle
notifications remain visible events. Collapse, timing and locally mounted history share
these boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+2366 -4140
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -131,6 +131,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -154,7 +155,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
}
}
@@ -18,7 +18,9 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -51,6 +53,7 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
@@ -984,7 +987,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -1001,8 +1004,10 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
// The complete turn arrives on reload; scrolling mounts its oldest rows
// without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
@@ -1290,6 +1295,49 @@ test.describe("Agent message submission", () => {
}
});
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -27,6 +27,10 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
}
});
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
@@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function readFileLinkTooltip(page: Page): Promise<void> {
const link = page.getByRole("link", { name: "the guide", exact: true });
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("guide.md");
}
async function leaveFileLink(page: Page): Promise<void> {
await page.mouse.move(5, 5);
await expect(page.getByRole("tooltip")).toHaveCount(0);
}
async function openLinkedFile(page: Page): Promise<void> {
await page.getByRole("link", { name: "the guide", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
async function returnToAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
await leaveFileLink(page);
}
async function switchToFileTab(page: Page): Promise<void> {
await page.getByRole("button", { name: "guide.md", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "tooltip-5811-",
title: "Tooltip regression",
featureValues: {
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await writeFile(
path.join(agent.cwd, "guide.md"),
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
);
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await readFileLinkTooltip(page);
await leaveFileLink(page);
await readFileLinkTooltip(page);
await openLinkedFile(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await switchToFileTab(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await leaveFileLink(page);
} finally {
await agent.cleanup();
}
});
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
if (settings.codeFontSize !== undefined) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
...settings,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
backToList,
closeSheet,
expectNoSheetOpen,
expectOneSheetOpen,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
@@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
const grandchild = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: child.id, title: "Sheet grandchild" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
@@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await test.step("a subagent opens in place of the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectOneSheetOpen(page);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
@@ -94,13 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectOpenTabCount(page, 1);
});
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await test.step("Back returns the sheet to the list", async () => {
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await expectOneSheetOpen(page);
});
await test.step("Close closes the list with the subagent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await closeSheet(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectNoSheetOpen(page);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
const grandchildSheet = sheet(page, grandchild.title);
await expectSheetAtSnap(page, grandchild.title);
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
await expect(grandchildSheet).toHaveCount(0);
await expect(childSheet).toBeVisible();
await expectOneSheetOpen(page);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
@@ -113,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
@@ -131,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
@@ -0,0 +1,81 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -42,6 +42,14 @@ const test = base.extend<{ agent: MockAgentSession }>({
},
});
// The outer meter owns the accessible usage label; its SVG is decorative.
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
const ring = meter.locator("svg");
await expect(ring).toHaveAttribute("aria-hidden", "true");
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
await expect(ring).not.toHaveAttribute("importantForAccessibility");
}
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
@@ -100,6 +108,7 @@ test.describe("context window meter", () => {
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
@@ -8,7 +8,11 @@ import {
openDaemonOverview,
restartDaemonInSettings,
expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -29,6 +33,57 @@ test("settings restart waits for a replacement worker while retaining the superv
}
});
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { expect, metroTest as test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
const PUBLISHED_DAEMON_VERSION = "0.8.0";
const COMPACT = { width: 390, height: 844 };
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: PUBLISHED_DAEMON_VERSION,
});
try {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ seededHost }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
},
{ seededHost: host },
);
await gotoAppShell(page);
await run();
} finally {
await daemon.close();
}
}
async function expectOutdatedNotice(page: Page): Promise<void> {
const callout = page.getByTestId("daemon-outdated-callout").first();
await expect(callout).toBeVisible({ timeout: 30_000 });
await expect(callout).toContainText("Daemon out of date");
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
const update = page.getByTestId("daemon-outdated-callout-update").first();
await expect(update).toBeVisible();
await expect(update).toBeEnabled();
}
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
page,
}) => {
test.setTimeout(180_000);
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
});
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize(COMPACT);
await withOutdatedDaemon(page, async () => {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expectOutdatedNotice(page);
});
});
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
@@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => {
await repo.cleanup().catch(() => undefined);
}
});
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
test.setTimeout(300_000);
const corpus = await createPullRequestCorpusRepository();
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: corpus.repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create wrap performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await instrumentDiffLatency(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
const openStartedAt = performance.now();
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
const open = {
readyMs: Math.round(performance.now() - openStartedAt),
...(await readDiffDocumentRenders(page)),
};
const toggles: WrapToggleMeasurement[] = [];
for (const name of [
"wrap on (cold)",
"wrap off (cold)",
"wrap on (warm)",
"wrap off (warm)",
]) {
toggles.push(await measureWrapToggle(page, name));
}
const unwrappedScroll = await continuouslyScrollCanvas(page);
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
const wrappedScroll = await continuouslyScrollCanvas(page);
const report = {
scenario: "wrap-toggle",
sourceFiles: corpus.fileCount,
sourceLines: corpus.lineCount,
open,
toggles,
scroll: {
unwrapped: summarizeScroll(unwrappedScroll),
wrapped: summarizeScroll(wrappedScroll),
},
};
await attachPerformanceReport(testInfo, report);
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
const [wrapOn, wrapOff] = toggles;
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
} finally {
await client.close().catch(() => undefined);
await corpus.repo.cleanup().catch(() => undefined);
}
});
});
interface WrapToggleMeasurement {
name: string;
/** Pointer down on the toolbar button to two painted frames after the document resized. */
latencyMs: number;
scrollHeightBefore: number;
scrollHeightAfter: number;
longestTaskMs: number;
longTaskTotalMs: number;
diffDocumentCommits: number;
diffDocumentRenderMs: number;
}
interface LongTask {
startTime: number;
duration: number;
}
interface DiffLatencyScope {
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
__PASEO_RESET_RENDER_PROFILE__?: () => void;
__PASEO_LONG_TASKS__?: LongTask[];
__PASEO_WRAP_TOGGLE_AT__?: number;
}
async function createPullRequestCorpusRepository() {
const files = pullRequestCorpus();
const repo = await createTempGitRepo("diff-wrap-perf-", {
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
});
for (const file of files) {
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
}
return {
repo,
fileCount: files.length,
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
};
}
async function instrumentDiffLatency(page: Page): Promise<void> {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
scope.__PASEO_LONG_TASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
});
}
async function readDiffDocumentRenders(
page: Page,
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === "DiffDocument",
);
return {
diffDocumentCommits: samples.length,
diffDocumentRenderMs: Math.round(
samples.reduce((total, sample) => total + sample.actualDuration, 0),
),
};
});
}
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
const scroll = page.getByTestId("git-diff-scroll");
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
await page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RESET_RENDER_PROFILE__?.();
scope.__PASEO_LONG_TASKS__ = [];
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
document.addEventListener(
"pointerdown",
() => {
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
},
{ capture: true, once: true },
);
});
await page.getByTestId("changes-toggle-wrap-lines").click();
const measured = await scroll.evaluate(async (element, before) => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const deadline = performance.now() + 240_000;
while (element.scrollHeight === before && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const doneAt = performance.now();
// Long task entries are delivered after the task ends; give the observer a turn.
await new Promise((resolve) => setTimeout(resolve, 50));
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
);
return {
latencyMs: Math.round(doneAt - startedAt),
scrollHeightAfter: element.scrollHeight,
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
};
}, scrollHeightBefore);
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
}
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
const { frameSignatures: _frameSignatures, ...summary } = report;
return summary;
}
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
@@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page
});
});
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
@@ -1,29 +1,90 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
const { agent, serverId } = published;
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
await test.step("reconnect the browser and reload the old daemon history", async () => {
observationSockets.clear();
await page.reload();
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expect.poll(() => observationSockets.size).toBe(1);
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
await published.close();
}
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
}
});
@@ -1,3 +1,13 @@
import {
withRetainedPlugin,
editRetainedDraft,
expectRetainedDraft,
expectStoredPluginTitle,
hideRetainedPreferences,
showRetainedPreferences,
expectChangedPlugin,
expectRemovedPlugin,
} from "../support/helpers/plugin-reconnect";
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -188,3 +198,51 @@ test("plugin content and host labels follow app language without losing an open
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
page,
}) => {
test.setTimeout(180_000);
await withRetainedPlugin(page, async (probe) => {
await probe.open();
await editRetainedDraft(page, "Connected draft", 0);
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
await probe.disconnect();
await expectRetainedDraft(page, "Connected draft", 1);
await editRetainedDraft(page, "Offline draft", 1);
await probe.changeStoredTitle("Saved by another client");
await expectStoredPluginTitle(page, "Agent monitor");
await probe.reconnect();
await expectStoredPluginTitle(page, "Saved by another client");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("inactive settings refresh without replacing the installation", async () => {
await hideRetainedPreferences(page);
await probe.disconnect();
await probe.changeStoredTitle("Saved while preferences hidden");
await probe.reconnect();
await showRetainedPreferences(page);
await expectStoredPluginTitle(page, "Saved while preferences hidden");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("changed code replaces the retained installation after reconnect", async () => {
await probe.disconnect();
await probe.changeCode();
await expectRetainedDraft(page, "Offline draft", 2);
await probe.reconnect();
await expectChangedPlugin(page);
await editRetainedDraft(page, "New code draft", 0);
});
await test.step("removal is reconciled after reconnect", async () => {
await probe.disconnect();
await probe.remove();
await expectRetainedDraft(page, "New code draft", 1);
await probe.reconnect();
await expectRemovedPlugin(page);
});
});
});
@@ -1,4 +1,5 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -6,6 +7,8 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -1,5 +1,7 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
// A provider subagent has no composer; its callout is the way back to the parent.
await page.getByTestId("parent-agent-callout-open").click();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
@@ -34,8 +34,218 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
function selectedText(page: Page) {
return page.evaluate(() => window.getSelection()?.toString().trim());
}
const CHECKS_QUESTION = "Which checks should run?";
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
async function openLinkedQuestions(page: Page, context: BrowserContext) {
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-option-links-",
title: "Question option links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
return { session, card: page.getByTestId("question-form-card").first() };
}
async function dragAcross(page: Page, target: Locator) {
const bounds = await target.boundingBox();
if (!bounds) throw new Error("Text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
}
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
const popup = context.waitForEvent("page");
await link.click();
await expect.poll(async () => (await popup).url()).toBe(url);
}
async function expectChecked(option: Locator, checked: boolean) {
await expect(option).toHaveAttribute("aria-checked", String(checked));
}
test.describe("Question option text", () => {
test("links in an option label or description open without answering", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const notYet = card.getByRole("radio", { name: "Not yet" });
await clickLinkAndExpectPage(
context,
notYet.getByRole("link", { name: "status" }),
"https://example.com/status",
);
await clickLinkAndExpectPage(
context,
done.getByRole("link", { name: "https://example.com/logs" }),
"https://example.com/logs",
);
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await expectChecked(notYet, false);
} finally {
await session.cleanup();
}
});
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const description = done.getByTestId("question-form-option-description");
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await done.getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
} finally {
await session.cleanup();
}
});
test("double-clicking a single-select label answers once", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
} finally {
await session.cleanup();
}
});
test("multi-select description links and selection leave the option unchanged", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
const description = lint.getByTestId("question-form-option-description");
await clickLinkAndExpectPage(
context,
description.getByRole("link", { name: "https://example.com/lint" }),
"https://example.com/lint",
);
await expectChecked(lint, false);
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, false);
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, true);
} finally {
await session.cleanup();
}
});
});
test.describe("Question prompt pagination", () => {
test("question links open without answering, and the question text is selectable", async ({
page,
context,
}) => {
test.setTimeout(180_000);
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-links-",
title: "Question links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
const card = page.getByTestId("question-form-card").first();
const question = card.getByTestId("question-form-current-question");
await expect(question).toHaveText(LINKED_QUESTION);
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
const bounds = await question.boundingBox();
if (!bounds) throw new Error("Question text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
const popup = context.waitForEvent("page");
await question.getByRole("link", { name: "the setup guide" }).click();
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
for (const option of ["Done", "Not yet"]) {
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
"aria-checked",
"false",
);
}
} finally {
await session.cleanup();
}
});
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
@@ -1,3 +1,7 @@
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -25,6 +29,8 @@ import {
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
setIdleAgentRelease,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -137,6 +143,100 @@ test.describe("Settings host page", () => {
}
});
test("idle agent release changes live on one host and stays saved without restarting", async ({
page,
}) => {
test.setTimeout(120_000);
const primaryServerId = getServerId();
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "idle-release-settings",
port: daemon.port,
});
const originalPid = (await client.getDaemonStatus()).pid;
const expectLiveSavedSetting = async (enabled: boolean) => {
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
};
const hosts = [
{
serverId: primaryServerId,
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Idle release host",
endpoint: `127.0.0.1:${daemon.port}`,
},
];
const openHostAgentsInFreshApp = async (serverId: string) => {
await seedSavedSettingsHosts(page, hosts);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
};
try {
await test.step("idle agent release starts on and turns off", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, true);
await setIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
});
await test.step("a fresh app reads the saved setting from the same running host", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
await setIdleAgentRelease(page, true);
await expectLiveSavedSetting(true);
await setIdleAgentRelease(page, false);
});
await test.step("other hosts keep their own setting", async () => {
await selectSettingsHost(page, primaryServerId);
await openHostSection(page, primaryServerId, "agents");
await expectIdleAgentRelease(page, true);
await expectLiveSavedSetting(false);
});
} finally {
await client.close();
await daemon.close();
}
});
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
const configPath = path.join(daemon.paseoHome, "config.json");
try {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openHostSection(page, daemon.serverId, "agents");
await expectIdleAgentRelease(page, true);
// A real filesystem failure, with the daemon and RPC path intact.
await rename(configPath, `${configPath}.saved`);
await mkdir(configPath);
await page.getByRole("switch", { name: "Release idle agents" }).click();
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
"Failed to read",
);
await expectIdleAgentRelease(page, true);
await rm(configPath, { recursive: true });
await rename(`${configPath}.saved`, configPath);
await setIdleAgentRelease(page, false);
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -6,7 +6,10 @@ import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
openSettingsSection,
selectSettingsHost,
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
await selectSettingsHost(page, getServerId());
await openSettingsSection(page, "appearance");
await openSettingsHostSection(page, getServerId(), "connections");
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
@@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
@@ -43,6 +52,8 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -52,7 +63,167 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible();
}
test.describe("Settings sidebar scroll position", () => {
test.use({ viewport: { width: 1000, height: 500 } });
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await scrollSettingsSidebarToEnd(page);
await page.setViewportSize({ width: 1000, height: 1100 });
await expectSettingsSidebarScrollOffset(page, 0);
await page.setViewportSize({ width: 1000, height: 500 });
await openSettingsSection(page, "appearance");
await expectSettingsSidebarScrollOffset(page, 0);
await scrollSettingsSidebarToEnd(page);
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
const detailOffset = await scrollSettingsDetailDown(page);
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
await scrollSettingsSidebarUp(page, 60);
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
await expectSettingsDetailScrollOffset(page, detailOffset);
});
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const scrollTop = await scrollSettingsSidebarToEnd(page);
await selectSettingsHostSectionsRapidly(page);
await expectSettingsSidebarScrollOffset(page, scrollTop);
await expectSettingsSidebarScrollSettled(page, scrollTop);
});
test("keeps the sidebar position when switching between app and host sections", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
for (const section of [
"providers",
"terminals",
"providers",
"terminals",
"providers",
] as const) {
await openSettingsHostSection(page, getServerId(), section);
await expect(page.getByTestId("page-title")).toHaveText(
section === "providers" ? "Providers" : "Terminals",
);
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
}
const sidebar = page.getByTestId("settings-sidebar");
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
await openSettingsSection(page, "about");
await expectAboutContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
await openSettingsSection(page, "diagnostics");
await expectDiagnosticsContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
});
});
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -77,6 +248,28 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("host choice survives app sections and host screen replacements", async ({ page }) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
]);
await gotoAppShell(page);
await openSettings(page);
await selectSettingsHost(page, secondaryServerId);
await openSettingsHostSection(page, secondaryServerId, "connections");
await openSettingsSection(page, "appearance");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsSection(page, "general");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsHostSection(page, secondaryServerId, "connections");
await selectSettingsHost(page, primaryServerId);
await openSettingsSection(page, "about");
await expectSettingsHostPickerLabel(page, "First horse");
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
@@ -147,6 +340,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
@@ -186,8 +415,19 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
await openWorkspace(page, workspace);
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
await page.setViewportSize({ width: 1280, height: 900 });
await openWorkspace(page, workspace);
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 390, height: 844 });
});
await page.getByRole("button", { name: "Open menu", exact: true }).click();
// The compact sidebar shows only native callouts, so the prompt stays out.
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
await expect(worktreeSetupCallout).toHaveCount(0);
await page.getByTestId("sidebar-hosts-trigger").click();
await page.getByRole("button", { name: /Open .* settings/ }).click();
@@ -223,6 +463,15 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
});
test("removing the last active host returns to welcome after settings closes", async ({
@@ -241,3 +490,76 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}

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