Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 6d03b3687f chore(release): refresh Nix dependency hash for 0.13.2 2026-10-26 12:09:09 +01:00
Mohamed Boudra b6e082a724 chore(release): cut 0.13.2
(cherry picked from commit d2941157a8)
2026-10-26 12:06:08 +01:00
Mohamed Boudra 11d34e2a69 docs(release): add 0.13.2 native patch notes
(cherry picked from commit 870c969fdb)
2026-10-26 12:00:26 +01: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
Mohamed Boudra d34d17a907 chore(release): cut 0.13.0 2026-10-24 08:02:33 +02:00
Mohamed Boudra 6d655ac1fe docs: update changelog for 0.13.0 2026-10-24 07:58:40 +02:00
a54cc6dd11 chore(acp): refresh pinned provider packages (#7781)
* chore(acp): refresh pinned provider packages

Co-authored-by: Kim <androidj@qq.com>
Co-authored-by: Louis Deconinck <louis.dck@gmail.com>

* test(app): capture the visible composer across retained tabs

The settings regression sampler selected the first mounted composer even when it belonged to an inactive retained tab. Mount both tabs before capturing so the test consistently covers that case, and sample the visible composer.

---------

Co-authored-by: Kim <androidj@qq.com>
Co-authored-by: Louis Deconinck <louis.dck@gmail.com>
2026-10-24 12:36:10 +07:00
c1b257b8f4 fix(usage): show Cursor quota percentages without counting bonus spend as overage (#7778)
* test: checkpoint usage helper before manual QA fixes

* fix: show Cursor plan quotas and filter disabled usage providers

* fix: display Cursor plan and capitalize Codex plan labels

* fix: reconcile usage refreshes across account and provider changes

* fix: keep usage cards in alphabetical order

* docs: add usage fix QA evidence and provider contract

* test: replace usage hook mocks with an injected client

* fix: keep usage footer in alphabetical order (pair-review item 2)

* refactor: remove usage provider enablement filtering

---------

Co-authored-by: kb <kb@wardbitz.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Stackie Jia <jsq2627@gmail.com>
2026-10-24 06:55:07 +02:00
f3d74b8575 fix(plugins): restore deprecated useRevealedText compatibility (#7780)
The 0.13 UI entry rename dropped the reveal hook from the 0.12 runtime.
Keep it on the legacy entry until that entry is removed, with deprecated
SDK typing and migration guidance.

Closes #7577
Closes #7722

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
2026-10-24 11:44:08 +07:00
Mohamed Boudra 686588c44b feat(app): restore manual workspace sorting (#7771)
Keep manual workspace order separate from automatic sorting so surviving pre-beta arrangements and subsequent drags remain available across mode changes. Created stays the default; data already discarded by the beta cannot be reconstructed.
2026-10-24 09:06:05 +07:00
paseo-bot[bot] 48623801a1 fix(app): keep tablet sidebar controls above system navigation (#7768)
The wide sidebar reserved only the top safe area, leaving its footer under Android navigation UI. Apply the existing bottom inset to the same interior and add a native geometry regression check.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-24 03:01:19 +02:00
Stephen ZhouandClaude Opus 5.5 632a7f8859 fix(app): keep live workspace rows when a late creation snapshot arrives (#7758)
A workspace.create.response carries the descriptor captured at provision
time but is sent after the initial agent starts. A pin or title change made
in between reaches the app first as a live update; the late response then
replaced that row with the older snapshot, so a new pin vanished from the
sidebar until the next directory update.

Creation and progress snapshots now only seed rows the replica has not
received; existing rows stay owned by directory updates. Archive rollback
still restores a row that was removed.

Closes #5447

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:05:57 +02:00
paseo-bot[bot] 48d2c9ec36 Keep restored Claude background notifications from appearing twice (#7737)
* fix(claude): restore each background task notification once

Queued and delivered copies of one completion had different synthetic tool identities. Prefer their shared task ID so existing timeline projection folds them into one notification.

* fix(claude): preserve separate notifications from one task

A task can emit several Monitor events, including identical text. Assign replay identity per notification body and occurrence, pairing queue and delivery records before timeline projection instead of merging every event by task ID.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 23:54:08 +02:00
paseo-bot[bot]andgrueneisfabian ca894d49a5 fix(voice): keep prefetched sentences available for playback (#7756)
Unread speech bodies lost their owning HTTP response while earlier sentences played. Garbage collection then cancelled those bodies and the playback manager skipped their audio. Consume each response inside the provider before returning its audio stream, matching the existing complete-clip playback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: grueneisfabian <174708694+grueneisfabian@users.noreply.github.com>
2026-10-23 23:01:02 +02:00
Ni Xiaoandnistream d6fa231894 fix(plugin): restore ACP history replayed during session load (#7717)
ACP agents replay a loaded session's session/update notifications before
session/load resolves, but AcpRuntime.open registered nativeSessionId only
after the response, so the session filter dropped every replayed update and
restored sessions opened with an empty timeline.

Register the native id before the request, drain the notification lane once
the response arrives so the replay completes before session.opened, and
follow the open request's history contract: replayed updates rebuild the
timeline under history "replay" and are dropped under history "skip".
Replayed user_message_chunk updates now restore user messages through the
chunk fallback scheme, so multi-chunk messages keep their boundaries; live
user chunks stay dropped because admitPrompt already echoes the prompt.

Co-authored-by: nistream <nistream@users.noreply.github.com>
2026-10-23 18:05:54 +02:00
paseo-bot[bot] fed19f3d29 fix(app): identify Android DNS connection failures (#7701)
Recognize explicit name-resolution failures before TLS keywords in hostnames, while preserving certificate-not-found handling and raw details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 14:57:19 +02:00
paseo-ai[bot] 74dc764f0d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-23 12:38:01 +00:00
Mohamed Boudra 30d8cb0447 chore(release): cut 0.13.0-beta.2 2026-10-23 14:28:15 +02:00
Mohamed Boudra d91d61c6eb docs: update changelog for 0.13.0-beta.2 2026-10-23 14:23:00 +02:00
Mohamed Boudra 59b01b33f1 fix(app): build iOS releases with the foldable-capable SDK (#7705)
EAS auto selects Xcode 26.6 for Expo 57, leaving releases in phone compatibility mode despite scene support. Pin Xcode 27.1 for the native resizing opt-in.
2026-10-23 19:00:15 +07:00
f3b0199c13 Open large Claude histories without whole-transcript duplication (#7431)
* fix(server): skip Claude subagent replay over a transcript size budget

Loading a session's subagent transcripts all at once OOMs the daemon
worker on large Claude sessions (#5820: a 395 MB parent plus 269
sidechain transcripts, 757 MB total, crash-loops the worker). Stat the
sidechain files before reading any and leave them unread when the
parent plus sidechain transcripts exceed a replay budget (256 MB by
default, PASEO_CLAUDE_REPLAY_BUDGET_MB to override); the parent history
replays unchanged, which is the same state as moving the subagent
transcripts out of the way. Also parse the parent transcript once
instead of twice while rebuilding sidechain ownership.

* fix(server): accept fractional PASEO_CLAUDE_REPLAY_BUDGET_MB values

parseInt read 0.5 as 0, so a fractional budget silently fell back to
the 256 MB default — the opposite of what a small-host operator asked
for. Parse the value as a float and pin the behavior with a regression
test.

* Stream Claude transcript replay without discarding subagent history

Read JSONL incrementally, retain ownership facts instead of raw records, and replay workflow payloads through a chronological byte-location index. Preserve all subagent history and remove the input byte budget.

Co-authored-by: ZouYusheng <admin@tj7mun.org>

* fix(server): propagate Claude transcript replay read errors

* fix(server): bound Claude replay output and index legacy history

Apply the existing tool content limit before replay retains events, copying truncated prefixes so sliced strings do not pin the original output. Index each child once and own transcript snapshots through ingestion to preserve history across provider file deletion. Keep full-scale memory reproductions opt-in.

* fix(server): replay Claude history without temporary transcript copies

Keep the original descriptors open until synchronous replay finishes. Indexed passes survive unlink and rename without requiring a second copy of the session on temporary storage. Close every descriptor on success and failure.

* fix(claude): replay large child histories without spread

Streaming removes whole-transcript duplication, but folding many visible child events still passed the complete aggregate as arguments to Array.push. Large histories exceed the V8 argument limit, the loader catches a RangeError, and history remains empty. Append replay observations iteratively and cover 160,000 aggregate child events.

Adapted from #7511, independently reproduced with the public resumeSession and streamHistory interfaces.

Co-authored-by: Alin Jiang <zonglinj@amazon.com>

* test(claude): cover parent-only replay above the string limit

Exercise the same parent-only resume path routinely with a small corpus, and use PASEO_TEST_LARGE_CLAUDE_REPLAY=1 for the 550 MiB string-limit reproduction. The eager loader returns empty history for this corpus; incremental replay preserves the visible parent event.

---------

Co-authored-by: Yusheng Zou <admin@tj7mun.org>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Alin Jiang <zonglinj@amazon.com>
2026-10-23 13:08:09 +02:00
Mohamed Boudra 582beeb10b fix(app): preserve compact sidebar context under sheets (#7693)
Keep compact sidebars open while users inspect Search, Import session, Add project, or file sheets. Reveal the resolved destination when navigation commits, and place compact workspace sheets above both sidebars while preserving shared editing and cache state.
2026-10-23 15:57:12 +07:00
Mohamed Boudra 6405edd8dc feat(app): show consistent agent and workspace status in search (#7690)
Share tab agent presentation and effective workspace status with Command Center. Preserve unchanged result identities so live directory updates only rerender the changed row, with bounded retention and browser regression coverage.
2026-10-23 14:35:27 +07:00
Mohamed Boudra 4b9b2fe130 fix(app): preserve manual Command Center scrolling during live updates (#7688)
Treat selection and permission to reveal it separately. Result publication and layout updates must not pull a manually scrolled viewport back to the active row; user navigation requests a one-shot reveal.
2026-10-23 14:04:43 +07:00
Mohamed Boudra 4fe006df86 feat(plugins): unify panel props and persist tab state (#7685)
* feat(plugins): unify panel props and persist tab state

* refactor(plugins): make agent panel registration canonical

* docs(plugins): update Chinese panel API reference and migration
2026-10-23 13:26:19 +07:00
Mohamed Boudra 21e7ccb036 Restore agent finish notifications after daemon restart (#7683)
* fix(server): restore agent finish notifications after daemon restart

Finish observers lived only in memory, so a restarted daemon silently lost the wakeup parents relied on. Persist subscription intent through notification dispatch acknowledgment and reconcile local and remote children after bootstrap.

* fix(server): reject finish subscriptions for unsent remote prompts

Connection and snapshot preflight failures happen before remote prompt transmission. Reject those candidates while retaining ambiguous sends, and preserve the host identity in callbacks. Exercise local permission cycles and storage errors through MCP.

* fix(server): notify parents when remote finish observation is exhausted

Three failed observations or a removed registration consume the watch after the parent accepts dispatch. Report unknown child status, retain the captured outcome through delivery failures, and keep connecting attempts outside the failure budget.

* fix(server): describe exhausted child observation accurately

Remote RPC failures do not prove the host is offline. Report the failed observation and unknown child status, and give Windows integration polling more margin.
2026-10-23 13:21:29 +07:00
89727f43bc fix(server): require the daemon password on relay connections (#7668)
* fix(server): require the daemon password on relay connections

A password-protected daemon still admitted relay clients that sent no
credential in their encrypted hello, so a leaked pairing link alone gave
full control. Remove the COMPAT(relayPasswordOptional) shim: relay clients
now need the password like direct ones.

Fixes #4087

* test(server): cover relay password admission end to end

Run a password-protected daemon behind a local relay and connect with
the real host client over relay://: no password and a wrong password are
refused, the right password is admitted and serves RPCs.

* test(server): run relay password regression in CI

* test(ci): include relay password regression in command expectation

---------

Co-authored-by: Nicholas Maglowsch Salgueiro <21068323+nickmaglowsch@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-23 13:15:14 +07:00
Mohamed Boudraandsam 99a1d9a7a2 fix(app): keep the iPhone skill picker above the composer and keyboard (#7679)
Keep iOS command and file suggestions above the composer and keyboard across chat, workspace drafts and New workspace. Share transform-free anchor measurement and keyboard translation through keyboard/shift.

Includes failing-first native placement and exact-insertion coverage, plus two CI test reliability fixes.

Co-authored-by: sam <76036008+masz77@users.noreply.github.com>
2026-10-23 11:41:00 +07:00
Mohamed Boudra 3c3c272925 fix(server): publish workspace snapshots without UI subscribers (#7681)
Computed snapshots must reach the runtime store and auto-archive even when no UI observes the workspace. Reject retired targets so late refreshes cannot replace newer published state.
2026-10-23 11:18:25 +07:00
445 changed files with 23974 additions and 4754 deletions
+48 -6
View File
@@ -1,6 +1,21 @@
# Changelog
## 0.13.0-beta.1 - 2026-10-22
## 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.
@@ -14,18 +29,26 @@ paseo host add 'relay://...' --label laptop
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### Improved
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -35,25 +58,44 @@ paseo host add 'relay://...' --label laptop
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+48 -6
View File
@@ -1,6 +1,21 @@
# 更新日志
## 0.13.0-beta.1 - 2026-10-22
## 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、插件文件面板,以及插件翻译。
@@ -14,18 +29,26 @@ paseo host add 'relay://...' --label laptop
### 新增
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -35,25 +58,44 @@ paseo host add 'relay://...' --label laptop
### 插件
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+1 -1
View File
@@ -42,7 +42,7 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
## Local daemon trust boundary
+73 -4
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
@@ -79,7 +125,30 @@ Users can also detach an existing subagent from the subagents track. Detach is d
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt
crash can lose queued prompts. A durable send receipt remains pending until actual dispatch
acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued
admission within their original timeout and cancellation scope.
Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module
persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses
`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt
acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch
accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same
caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
retries the captured notification; it does not change the child's reported outcome. Remote host loss
follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining
outstanding intent. A crash after successful dispatch but before removal commits can repeat the
notification. Clean shutdown can also flush an already queued callback while retaining its intent,
so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
@@ -219,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) 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. 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.
+19 -4
View File
@@ -44,10 +44,25 @@ connection candidates, including direct connections and routes discovered throug
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
the destination workspace keeps its existing disconnected or reconnecting behavior.
Finish notifications use the existing idle wait, including a pending initial run or an already idle
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
no additional turn attribution or delivery guarantee.
Finish notifications name the destination server and child agent. The caller's daemon owns durable
subscription intent and restores observation through the same routed wait and permission observation
used during ordinary operation. Restarting the caller's daemon does not imply that a remote child
stopped. Three failed remote observation attempts end the subscription with a notification that the
child could not be observed and its status is unknown. This does not prove the host is offline: remote
RPC failures also prevent observation. Attempts use capped backoff; a connection still
in progress does not spend that budget. A confirmed removed registration reports host unavailability
immediately through the same path. Neither notice claims the child finished. Intent remains stored
until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The
parent decides whether to inspect the child or delegate again.
A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt
response retains intent because the destination may have admitted it; observation then follows the
same retry policy. A confirmed missing child reports unavailability.
Remote creation and local subscription persistence are separate commits. A failed local write reports
failure even if the destination already created the child; inspect it before retrying creation.
See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment,
and duplicate-delivery semantics. Concurrent prompts gain no turn attribution.
## Connections and recovery
+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.
---
+3 -1
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.
---
+8 -5
View File
@@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
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 the whole compact workspace, including the header and Explorer. A sheet's header is its drag
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. The sheets present
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
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
[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.
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+34 -18
View File
@@ -115,11 +115,10 @@ quietly relying on:
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
translate/padding styles. The composer and chat content must both read that
provider-owned value. A portal'd popover is outside the composer tree — it
does not get that transform unless you apply it yourself.
- **Transforms.** The composer moves with the keyboard through a
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
it does not move with the composer unless it renders its own
`KeyboardTranslateView` with the same translation.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
@@ -158,7 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, settled stream inset, and explorer padding
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
@@ -256,11 +255,27 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native
animation-end events because the controller can retain a nonzero value after
the keyboard closes.
Measure portal anchors while the dock is at rest. If a popover opens during
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
dock's current layout and transform. Snapshot the shift at measurement time and
apply only the subsequent shift delta to the animated `bottom`. Adding the full
shift moves the portal twice and can place it over the composer controls.
Anchor a portal to content inside a `KeyboardTranslateView` with
`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the
translation scope is React context and does not cross it (Gotcha 7). It returns:
- `measureAtRest(element)`: the element's window rect with the keyboard
translation left out, or null once the anchor unmounts. Lay the popover out
from this rect. It assumes the translate view's parent does not move with the
keyboard, which holds for the composer dock.
- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so
the popover moves with the anchor on the same driver, including centered
clearance and disabled translation.
- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's
height with it; never animate height per frame.
Do not measure an anchor with `measureInWindow` and correct it by the keyboard
shift. On iOS the translate view uses the native driver, and `measureInWindow`
includes its transform only when the view happened to re-render since the
keyboard moved. Both results were observed, so any correction double-lifts the
popover or leaves it behind the keyboard. `measureAtRest` measures through
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
settles; the composer's resting position changes as the draft grows.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
@@ -273,7 +288,8 @@ not be required to repair the offset.
The generic anchored-overlay rule is:
1. Measure the anchor with `measureInWindow`.
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
moves with the keyboard (Gotcha 3).
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
@@ -347,10 +363,10 @@ track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
close. This includes native centered dialogs: separate React Native Modals cannot present a
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
portal host and container measurement while the sheet still joins the same stack. That export is
root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host
and container measurement while forwarding the same modal queue. Compact
[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars;
wide layouts keep local geometry. That export is
what `patches/@gorhom+bottom-sheet+*.patch` adds.
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
@@ -405,8 +421,8 @@ Before you write a new one, ask:
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
+37 -1
View File
@@ -53,7 +53,18 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
tab, and New workspace. It checks empty-content taps and header taps at the cap
without changing the input height. Each
host also opens command/file autocomplete, the model selector, the attachment
menu, and the forge picker from both keyboard states. It then submits a long
menu, and the forge picker from both keyboard states. Autocomplete opens with
the keyboard closed, before the keyboard opens, and after it settles. Each time
the expected best match must be drawn whole between the header and the composer,
above the IME frame, and tapping it there must produce the exact composer text.
Accessibility reports rows behind the keyboard too, so
`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares
bounds. Android leaves the chat and composer out of the accessibility tree while
suggestions are open, so the composer bounds come from a snapshot with an empty
input in the same keyboard state. The queries are partial names with known best
matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts
`/release` finds this repository's `release-beta` skill for the first Claude model,
and `@app` finds `packages/app`. It then submits a long
fixture from each host and checks native stream displacement twice: after the
response settles, and after returning to the bottom and letting the scrollbar
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
@@ -64,6 +75,31 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
To rerun only the autocomplete, model selector, attachment menu, and forge picker
checks on each host, skipping the chat baseline and the growth, dismissal, and
stream scenarios:
```bash
PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android
```
`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same
autocomplete checks on iOS for chat, a workspace draft tab, and New workspace,
with commands, files, and files under a grown draft. Suggestions open while the
keyboard opens (`fill` taps and types at once), after it settles, with it closed,
and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert
accessibility state and cannot compare bounds. Run it where `agent-device`
reaches the simulator, with the development app connected to the fixture host
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
+6 -1
View File
@@ -498,6 +498,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
@@ -570,7 +575,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
@@ -0,0 +1,15 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,15 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,15 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,15 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
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,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-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
+37 -70
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -30269,9 +30269,9 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz",
"integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
@@ -30280,7 +30280,7 @@
"peerDependencies": {
"react": "*",
"react-native": "0.83 - 0.86",
"react-native-worklets": "0.10.x"
"react-native-worklets": "0.10.x - 0.11.x"
}
},
"node_modules/react-native-safe-area-context": {
@@ -30406,9 +30406,9 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.4.tgz",
"integrity": "sha512-gbk0CxMrGywEp3SJAHO+2JVaCWtl4WyAYBIxLZy6Y113+mHHuq3FuT6iV+VcaBuJzuuN34mtdZ4j5JIlBDzmeA==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35780,14 +35780,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -41012,39 +41012,6 @@
}
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native-worklets": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz",
"integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/generator": "^7.27.1",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4",
"whatwg-fetch": "^3.6.20"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.86 - 0.88"
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -42124,7 +42091,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42120,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42171,8 +42138,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42194,7 +42161,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42188,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42409,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43339,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44949,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -154,7 +154,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"
}
}
@@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => {
}
});
});
test.describe("Compact Add project context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("canceling Add project preserves the sidebar", async ({ page }) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
});
test("successful Add project reveals the new workspace page", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName);
await page.keyboard.press("Enter");
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: projectPickerFixture.projectName,
projectPath: projectPickerFixture.projectPath,
});
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
});
});
@@ -18,7 +18,9 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -1290,6 +1292,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");
@@ -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",
@@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -137,3 +143,94 @@ test("unscoped command search keeps commands visible and reports file-search fai
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-delayed-scroll-",
title: "Delayed file search scroll",
repo: { files: paseoSizedFiles() },
});
try {
const responses = await holdDirectorySuggestionResponses(page);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible();
await panel.getByTestId("command-center-input").fill("index");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeVisible();
responses.hold();
await panel.getByTestId("command-center-input").fill("index.");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await responses.release();
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
await panel.getByTestId("command-center-input").fill("index.ts");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(nextOffset);
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+P");
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
await panel.getByTestId("command-center-input").fill("index");
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
} finally {
await seeded.cleanup();
}
});
test.describe("Compact file search context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("a file result opens a sheet above the left sidebar", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-file-",
title: "Compact file lookup",
repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] },
});
try {
await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("lookup.ts");
await search.getByRole("button", { name: "lookup.ts", exact: true }).click();
await expect(page.getByTestId("sheet-tab-body")).toBeVisible();
await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts");
await page
.getByTestId("sheet-tab-header")
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -1,7 +1,21 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import {
seedSearchableCommandCenter,
observeCommandCenterRowRenders,
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
@@ -16,12 +30,15 @@ test.use({
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("opening the command center keeps its first result fully visible", async ({ page }) => {
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
let newestWorkspaceId = seeded.workspaceId;
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
@@ -36,6 +53,7 @@ test("opening the command center keeps its first result fully visible", async ({
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
newestWorkspaceId = created.workspace.id;
}
await gotoAppShell(page);
@@ -48,6 +66,52 @@ test("opening the command center keeps its first result fully visible", async ({
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
} finally {
await seeded.cleanup();
}
});
test("live search updates render only the changed row and keep agents virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
} finally {
await seeded.cleanup();
}
@@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
@@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
model: "five-minute-stream",
initialPrompt: "Keep working on checkout retries",
});
try {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("workspace results show their title, host, and branch and open the workspace", async ({
page,
}) => {
@@ -156,3 +185,74 @@ test.describe("Command center workspaces", () => {
}
});
});
test.describe("Compact sidebar inspection", () => {
test.use({ viewport: { width: 390, height: 844 } });
async function openSidebar(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function expectSidebarPreserved(page: import("@playwright/test").Page) {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
test("canceling Search retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await openCommandCenter(page);
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("canceling Import session retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await page
.getByTestId("sidebar-project-empty-state")
.getByRole("button", { name: "Import session", exact: true })
.click();
await expect(page.getByTestId("import-session-sheet")).toBeVisible();
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByTestId("import-session-sheet")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("Search reveals the same workspace and a selected root agent", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-destination-",
title: "Compact destination",
});
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
});
await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("Compact destination");
await search
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectSubagentRowVisible(page, child.id);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
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 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 expectSheetAtSnap(page, child.title);
@@ -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);
@@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
await test.step("the selected row imports and opens the hydrated transcript", async () => {
await flow.importSession(scenario.importSessionId);
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await capture(page, testInfo, "08-mobile-agent-after-import.png");
});
@@ -228,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 });
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") {
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") {
const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true });
await button.click();
await page.reload();
if (view === "Source") {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
} else {
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
@@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
await rememberMarkdownViewAcrossReload(page, "Preview");
await rememberMarkdownViewAcrossReload(page, "Source");
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
// cursor and vim mode in the panel's toolbar.
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
@@ -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,
}) => {
@@ -223,6 +452,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 ({
@@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
@@ -6,6 +6,7 @@ import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
expectCompactSidebarOpen,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
@@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await expectCompactSidebarOpen(page);
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) {
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, workspace);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await openMobileAgentSidebar(page);
}
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
}
});
test("a subagent's parent pill returns to its parent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Refactor auth flow",
childTitle: "Write session tests",
});
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
const pill = page.getByTestId("composer-parent-agent-pill");
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
await pill.click();
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
}
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -0,0 +1,67 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
async function seedAnsweredAgent(title: string) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-collapse-",
title,
model: "ten-second-stream",
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
return replyRow - summaryRow - 1;
});
}
test.describe("Turn collapse", () => {
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
// Text chunks that stream back to back merge into one message, so the reply holds
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,93 @@
import { expect, test } from "../support/fixtures";
import {
apiKeyInput,
baseUrlInput,
expectSheetClosed,
expectSpeechSiteSummary,
openSpeechSite,
openVoiceSettings,
providerOption,
readSpeechSite,
resetSpeechSite,
saveSpeechSite,
withUnwritableDaemonConfig,
} from "../support/helpers/voice-settings";
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
// download. That makes the provider picker read-only here; the env key covers the key fallback.
const ENV_API_KEY = "sk-e2e-env-fallback";
const SAVED_API_KEY = "sk-e2e-saved-key";
const BASE_URL = "http://127.0.0.1:9/v1";
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
page,
}) => {
try {
await openVoiceSettings(page);
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
const sheet = await test.step("the env var decides the provider", async () => {
const opened = await openSpeechSite(page, "Listening");
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
await expect(providerOption(opened, "Local")).toBeDisabled();
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
return opened;
});
await test.step("a saved key and base URL win over the env key", async () => {
await apiKeyInput(sheet).fill(SAVED_API_KEY);
await baseUrlInput(sheet).fill(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.stt"))
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
});
await test.step("reopening shows the key as saved without revealing it", async () => {
const reopened = await openSpeechSite(page, "Listening");
await expect(apiKeyInput(reopened)).toHaveValue("");
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
});
await test.step("clearing the base URL keeps the saved key", async () => {
const sheetAgain = page.getByRole("dialog");
await baseUrlInput(sheetAgain).fill("");
await saveSpeechSite(sheetAgain);
await expectSheetClosed(page);
const site = await readSpeechSite("voiceMode.stt");
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
expect(site).not.toHaveProperty("baseUrl");
});
} finally {
await resetSpeechSite("voiceMode.stt");
}
});
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
try {
await openVoiceSettings(page);
const sheet = await openSpeechSite(page, "Speaking");
await baseUrlInput(sheet).fill(BASE_URL);
await withUnwritableDaemonConfig(async () => {
await saveSpeechSite(sheet);
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
});
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.tts"))
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
} finally {
await resetSpeechSite("voiceMode.tts");
}
});
@@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
# `overlays` runs only each host's autocomplete, model selector, attachment menu, and
# forge picker checks; `all` also runs the chat baseline and the full host scenario.
SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}"
if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then
echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2
exit 1
fi
if [[ -z "${SERVER_ID}" ]]; then
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
@@ -173,8 +180,10 @@ sleep 3
# Create the fixture while the client is connected so its directory replica observes the insert.
ad wait 'id="message-input-root"' 90000
# The compact header names the workspace, not the agent, so the chat waits below find
# the fixture by its prompt bubble.
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
npm run --silent cli -- run "${AGENT_TITLE}" \
--background \
--title "${AGENT_TITLE}" \
--provider mock \
@@ -206,238 +215,241 @@ clear_input
ad keyboard dismiss || true
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
# Chat-only invariants, then the long-draft checks on New workspace.
run_chat_baseline() {
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
}
# All hosts run the same scenario; only navigation differs.
# For the shared scenario, tap the editor's blank right edge once. Tapping
@@ -470,20 +482,80 @@ press_composer_control() {
adb shell input tap "${x}" "${y}"
}
# Opens suggestions with the keyboard closed, before it opens, or after it settled;
# checks their bounds and taps the best match where it is drawn. Accessibility keeps
# reporting rows behind the keyboard, so existence is not evidence. It also drops the
# composer while suggestions are open, so the composer bounds come from a reference
# snapshot with an empty input in the keyboard state the suggestions are checked in.
check_autocomplete() {
local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6"
local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}"
if [[ "${order}" == keyboard-closed ]]; then
prepare_composer closed
snapshot_json "${prefix}-reference.json"
adb shell input text "${query}"
else
prepare_composer open
# The composer follows the keyboard animation, which ends after the IME frame settles.
sleep 1
snapshot_json "${prefix}-reference.json"
if [[ "${order}" == before-keyboard ]]; then
adb shell ime set "${HELPER_IME}" >/dev/null
wait_for_ime false
adb shell input text "${query}"
open_editor_keyboard "${overlay_x}" "${overlay_y}"
else
adb shell input text "${query}"
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
snapshot_json "${prefix}.json"
local ime_top=""
if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi
local best_x best_y
read -r best_x best_y < <(
node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}"
)
adb shell input tap "${best_x}" "${best_y}"
sleep 1
snapshot_json "${prefix}-selected.json"
node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted"
}
# Drafts list commands for their selected provider, and a fresh install selects none.
select_claude_model() {
local x y h
press_composer_control combined-model-selector
ad wait 'id="agent-controls-model"' 10000
ad press 'id="agent-controls-model"' --settle
ad press 'id="model-provider-claude"' --settle
snapshot_json "${ARTIFACTS_DIR}/model-list.json"
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*')
adb shell input tap "${x}" "${y}"
ad wait 'id="agent-controls-thinking"' 10000
ad press 'label="Close"' --settle
}
run_host_overlays() {
local host="$1"
if [[ "${host}" != chat ]]; then
prepare_composer closed
select_claude_model
fi
# Chat lists the mock provider's command; drafts list the release skills in
# this repository's .claude/skills for the selected Claude model.
local command_query=/release command_match=/release-beta command_inserted="/release-beta "
if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi
for order in keyboard-closed before-keyboard after-keyboard; do
check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}"
check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"'
done
for mode in closed open; do
for query in / @README; do
prepare_composer closed
adb shell input text "${query}"
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
ad wait 'id="composer-autocomplete-popover"' 10000
local kind=commands
if [[ "${query}" == @* ]]; then kind=files; fi
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
done
prepare_composer "${mode}"
press_composer_control combined-model-selector
ad wait 'id="compact-provider-list"' 10000
@@ -656,19 +728,29 @@ run_host_scenario() {
run_host_scroll "${host}"
}
run_host() {
if [[ "${SCENARIO}" == overlays ]]; then
run_host_overlays "$1"
else
run_host_scenario "$1"
fi
}
if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
run_host chat
ad keyboard dismiss || true
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
# Only a draft offers to import a session.
ad wait 'id="composer-import-agent-pill"' 10000
run_host workspace-draft
ad keyboard dismiss || true
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
run_host_scenario new-workspace
run_host new-workspace
echo "Composer keyboard invariants passed"
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import process from "node:process";
import sharp from "sharp";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const [, , command, ...args] = process.argv;
@@ -115,11 +116,14 @@ if (command === "stream-moved") {
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
);
} else if (command === "rect") {
// A trailing `*` matches the first identifier with that prefix.
const [snapshotPath, identifier, edge] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null;
const node = snapshot.data.nodes.find(
(candidate) =>
candidate.identifier === identifier ||
(prefix !== null && candidate.identifier?.startsWith(prefix)) ||
candidate.label === identifier ||
(identifier === "editable" && candidate.type.endsWith("EditText")),
);
@@ -232,6 +236,27 @@ if (command === "stream-moved") {
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
}
} else if (command === "autocomplete-best") {
// Prints the center of the expected best match after checking its placement
// against the composer in the reference snapshot.
const [snapshotPath, referencePath, expected, imeTopArgument] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const reference = JSON.parse(await fs.readFile(referencePath, "utf8"));
const best = resolveAutocompletePlacement(snapshot, {
expected,
keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined,
reference,
});
process.stdout.write(`${best.x} ${best.y}\n`);
} else if (command === "composer-text") {
// Accessibility drops a trailing space, so compare without it.
const [snapshotPath, expected] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
const text = input?.value ?? input?.label ?? "";
if (text.trimEnd() !== expected.trimEnd()) {
throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`);
}
} else if (command === "same-region") {
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
const crop = {
@@ -0,0 +1,101 @@
// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device
// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that
// sit behind the keyboard, so existence is not evidence: compare their screen bounds.
const bottomOf = (rect) => rect.y + rect.height;
function findById(nodes, identifier) {
const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect);
if (!node) throw new Error(`Missing node: ${identifier}`);
return node;
}
function contains(outer, inner) {
return (
inner.x >= outer.x - 1 &&
inner.y >= outer.y - 1 &&
inner.x + inner.width <= outer.x + outer.width + 1 &&
bottomOf(inner) <= bottomOf(outer) + 1
);
}
/**
* The best match is the list row nearest the composer. Check that the popover sits
* between the app's header controls and the composer, that the keyboard (when shown)
* is below both, and that the row nearest the composer is fully drawn and is
* `expected`. A clipped best match fails: the nearest whole row is then another one.
* Return where to tap it. Android leaves the composer out of the accessibility tree
* while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input.
*/
export function resolveAutocompletePlacement(
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect;
const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect;
// The popover may cover the compact header, never the status bar above it.
const minimumTop =
nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0;
// iOS reports the keyboard in the app's tree; Android callers pass the IME frame.
const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover);
const gap = composer.y - popoverBottom;
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
}
if (popoverBottom > composer.y + 1) {
throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
);
}
if (popover.y < minimumTop - 1) {
throw new Error(
`Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`,
);
}
if (keyboardY !== undefined && popoverBottom > keyboardY + 1) {
throw new Error(
`Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`,
);
}
// Rows are the innermost labelled boxes spanning the popover. The list scrolls to
// its end, so the lowest row is the best match nearest the composer.
const candidates = nodes.filter(
(node) =>
node.rect &&
node.label &&
node.identifier !== "composer-autocomplete-popover" &&
!/scroll bar/i.test(node.label) &&
node.rect.height > 0 &&
// The popover border insets rows by a few pixels; their labels are narrower.
node.rect.width >= popover.width * 0.97 &&
contains(popover, node.rect),
);
const area = (rect) => rect.width * rect.height;
const rows = candidates.filter(
(outer) =>
!candidates.some(
(inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect),
),
);
if (rows.length === 0) throw new Error("Autocomplete has no visible rows");
const best = rows.reduce((lowest, row) =>
bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest,
);
const label = best.label.split(", ")[0];
if (label !== expected) {
throw new Error(`Best visible match is ${label}, expected ${expected}`);
}
return {
label,
x: Math.round(best.rect.x + best.rect.width / 2),
y: Math.round(best.rect.y + best.rect.height / 2),
popover,
composer,
keyboardTop: keyboardY ?? null,
};
}
@@ -0,0 +1,340 @@
// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
// development app connected to the fixture host. See docs/mobile-testing.md.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
const scenario = env("SCENARIO", "all");
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
}
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const udid = env("UDID");
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
const artifactsDir = env(
"ARTIFACTS_DIR",
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
);
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
// The release skills ship in this repository's .claude/skills.
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
// The chat fixture's mock provider lists one command.
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
function snapshot(name) {
const output = ad("snapshot", "--raw", "--json");
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
ad("screenshot", path.join(artifactsDir, `${name}.png`));
return JSON.parse(output);
}
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
const composerText = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
async function waitFor(name, predicate, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const snap = snapshot(name);
if (predicate(snap)) return snap;
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
await sleep(500);
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
);
return placement;
}
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
// draft has no stream, so open and close the model sheet, which takes input focus.
async function dismissKeyboard(snap) {
const composer = nodeById(snap, "message-input-root").rect;
ad("press", "4", String(Math.round(composer.y - 8)));
await sleep(1_000);
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
const sheetOpen = (sheet) =>
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
ad("press", 'id="combined-model-selector"');
await waitFor("dismiss-keyboard-sheet", sheetOpen);
ad("press", 'label="Close"');
// The input is not hittable until the sheet has gone.
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
}
// Tap past the end of the (short) last line so the caret lands after the mention.
// The input's right padding holds the fullscreen toggle; stay clear of it.
function focusAtEnd(snap) {
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
if (!input) throw new Error("Missing composer input");
ad(
"press",
String(Math.round(input.x + input.width * 0.7)),
String(Math.round(input.y + input.height - 6)),
);
}
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
async function clearInput(name) {
focusAtEnd(snapshot(`${name}-before-clear`));
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
const deleteKey = keyboard.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete" && node.rect,
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(Math.min(remaining, 100)),
"--interval-ms",
"40",
);
}
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function selectRow(name, row, inserted) {
ad("press", String(row.x), String(row.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
);
}
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
// fill taps the input and types at once, so suggestions open while the keyboard opens.
ad("fill", "editable=true", text);
assertPlacement(
`${name} opened while keyboard opens`,
await settledPlacement(`${name}-opening`, true),
expected,
);
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
const opened = assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`));
const closed = assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
await selectRow(name, reopened, inserted);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
async function selectClaudeModel(host) {
ad("press", 'id="combined-model-selector"');
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
ad("press", 'id="agent-controls-model"');
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
ad("press", 'id="model-provider-claude"');
const models = await waitFor(`${host}-models`, (snap) =>
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
);
const row = models.data.nodes.find(
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
).rect;
ad(
"press",
String(Math.round(row.x + row.width / 2)),
String(Math.round(row.y + row.height / 2)),
);
await waitFor(`${host}-model-selected`, (snap) =>
Boolean(nodeById(snap, "agent-controls-thinking")),
);
ad("press", 'label="Close"');
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
}
async function runHost(host) {
if (host !== "chat") await selectClaudeModel(host);
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
await runCase(host, "files", fileCase);
await runCase(host, "files-grown", {
text: `${grownPrefix}${fileCase.text}`,
expected: fileCase.expected,
inserted: `${grownPrefix}${fileCase.inserted}`,
});
await clearInput(`${host}-done`);
await dismissKeyboard(snapshot(`${host}-done`));
}
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
async function runLiveGrowth() {
await clearInput("live-growth");
ad("type", "Please do the following now /paseo");
const before = await settledPlacement("live-growth-before", true);
assertPlacement("before growth", before, "/paseo");
const inputHeight = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
ad("type", "--", "-committee");
const grown = await settledPlacement("live-growth-after", true);
if (inputHeight(grown) <= inputHeight(before))
throw new Error("Draft did not wrap onto a new line");
assertPlacement("live growth without reopening", grown, "/paseo-committee");
const deleteKey = grown.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete",
).rect;
ad(
"press",
String(deleteKey.x + deleteKey.width / 2),
String(deleteKey.y + deleteKey.height / 2),
"--count",
"10",
"--interval-ms",
"40",
);
const shrunk = await settledPlacement("live-growth-shrunk", true);
if (inputHeight(shrunk) !== inputHeight(before))
throw new Error("Draft did not shrink back to one line");
assertPlacement("live shrink without reopening", shrunk, "/paseo");
ad("type", "--", "-committ");
const partial = await settledPlacement("live-growth-partial", true);
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
throw new Error("Could not prepare a partial command for selection");
}
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
ad("press", String(row.x), String(row.y));
await waitFor("live-growth-selected", (snap) =>
sameText(composerText(snap), "Please do the following now /paseo-committee "),
);
await clearInput("live-growth-done");
}
async function runBoundedPreview() {
const prefix =
"Please do the following / testing inline selection.\n" +
"Line two with details.\nLine three with details.\nLine four with details.\n" +
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
await runCase("preview", "long", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "shown",
});
await runCase("preview", "long-hidden", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "hidden",
});
await runCase("preview", "longest", {
text: `${prefix}/paseo-plug`,
expected: "/paseo-plugin",
inserted: `${prefix}/paseo-plugin `,
selectionKeyboard: "shown",
});
await runCase("preview", "short", {
text: `${prefix}/com`,
expected: "/commit",
inserted: `${prefix}/commit `,
selectionKeyboard: "shown",
});
await runCase("preview", "files", fileCase);
}
if (scenario !== "all") {
ad(
"open",
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
"--platform",
"ios",
...deviceArgs,
);
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
if (scenario === "growth") await runLiveGrowth();
else if (scenario === "previews") await runBoundedPreview();
else throw new Error(`Unknown scenario: ${scenario}`);
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
process.exit(0);
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", ...deviceArgs);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);
@@ -0,0 +1,25 @@
# Inline code formatting
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
combined emphasis, strikethrough and linked code, plus bold code in headings,
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
code-to-body size ratio at every heading level; fenced code keeps the code-size
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
line metrics.
Use a development client rebuilt from this checkout. Follow the temporary entry
point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
links preserving code and emphasized prose fonts.
It runs in the existing `test:android` CI job. To run only this regression locally,
from `packages/app/android` after Expo prebuild:
```sh
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
```
@@ -0,0 +1,52 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
}));
const queryClient = new QueryClient();
const source = [
"Plain: `MMMMiiii`",
"Bold: **`MMMMiiii`**",
"Italic: *`MMMMiiii`*",
"Both: ***`MMMMiiii`***",
"Strike: ~~`MMMMiiii`~~",
"Link: [`MMMMiiii`](https://example.com)",
"# Heading **`MMMMiiii`**",
"## Heading *`MMMMiiii`*",
"### Heading [`MMMMiiii`](https://example.com)",
"#### Heading ~~`MMMMiiii`~~",
"##### Heading ***`MMMMiiii`***",
"###### Heading `MMMMiiii`",
"- List **`MMMMiiii`**",
"> Quote **`MMMMiiii`**",
"Normal **bold** and *italic* prose.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={styles.root}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.title}>Inline code formatting regression</Text>
<Button
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<MarkdownRenderer text={source} compact={compact} />
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
+5
View File
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
// New installs collapse finished turns; specs assert on the rows inside them, so they
// start from the full chat unless they seed their own settings.
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
},
{
daemon: testDaemon,
@@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult(
expect(overflow.overflow).toBe("hidden");
expect(overflow.whiteSpace).toBe("nowrap");
}
/** Hold real daemon responses until the user has finished the interaction under test. */
export async function holdDirectorySuggestionResponses(page: Page) {
let holding = false;
const pending: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
pending.push(() => ws.send(message));
} else {
ws.send(message);
}
});
});
return {
hold() {
holding = true;
},
async release() {
await expect.poll(() => pending.length).toBeGreaterThan(0);
holding = false;
for (const send of pending.splice(0)) send();
},
};
}
@@ -0,0 +1,90 @@
import { mkdirSync } from "node:fs";
import { expect, type Locator, type Page } from "@playwright/test";
import { createMockIdleAgent } from "./archive-tab";
import { seedWorkspace } from "./seed-client";
export async function seedSearchableCommandCenter() {
const seed = await seedWorkspace({
repoPrefix: "command-center-performance-",
title: "Search workspace",
git: false,
});
try {
const agents = [];
for (let index = 0; index < 60; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
title: `Search agent ${String(index).padStart(3, "0")}`,
}),
);
}
const workspaces = [];
for (let index = 0; index < 40; index += 1) {
const path = `${seed.repoPath}/workspace-${index}`;
mkdirSync(path);
const result = await seed.client.createWorkspace({
source: { kind: "directory", path, projectId: seed.projectId },
title: `Search workspace ${String(index).padStart(3, "0")}`,
});
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
workspaces.push(result.workspace);
}
return { ...seed, agents, workspaces };
} catch (error) {
await seed.cleanup();
throw error;
}
}
export async function observeCommandCenterRowRenders(page: Page) {
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
}
export async function searchCommandCenter(panel: Locator, query: string) {
await panel.getByTestId("command-center-input").fill(query);
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const renders = await page.evaluate(() =>
Object.fromEntries(
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
id.startsWith("CommandCenterResultRow:"),
),
),
);
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
}
@@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing(
.evaluate((element) => element.getBoundingClientRect().top);
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
}
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
await page.setViewportSize({ width: 1280, height: 500 });
const viewport = panel.getByTestId("command-center-results");
await viewport.hover();
await page.mouse.wheel(0, 300);
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
const samples = await sampleCommandCenterScroll(panel);
const offset = samples.at(-1)!;
expect(offset).toBeGreaterThan(100);
return offset;
}
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
const samples: number[] = [];
for (let frame = 0; frame < 30; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(viewport.scrollTop);
}
return samples;
});
}
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
const samples = await sampleCommandCenterScroll(panel);
expect(Math.min(...samples)).toBe(offset);
expect(Math.max(...samples)).toBe(offset);
}
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
await page.keyboard.press("ArrowDown");
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(offset);
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
await expect(second).toBeInViewport({ ratio: 1 });
}
@@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session");
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").click();
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
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 = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
);
}
async close() {
await this.page.keyboard.press("Escape");
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -44,6 +44,7 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
clearWorkspaceAttention(workspaceId: string): Promise<void>;
setWorkspaceLabel(input: {
@@ -147,6 +148,7 @@ export interface SeedDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
@@ -42,6 +42,7 @@ type HostSection =
| "pair-device"
| "agents"
| "metadata"
| "voice"
| "workspaces"
| "providers"
| "usage"
@@ -57,6 +58,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function revealSettingsSidebarSection(
page: Page,
section: SettingsSection,
): Promise<number> {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
.scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollSettled(
page: Page,
offset: number,
): Promise<void> {
// RN Web emits its final scroll callback 100 ms after scrolling stops.
await page.waitForTimeout(200);
await expectSettingsSidebarScrollOffset(page, offset);
}
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
for (let selection = 0; selection < 5; selection += 1) {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Providers", exact: true })
.click();
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Terminals", exact: true })
.click();
await expectSettingsHeader(page, "Terminals");
}
}
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
await page.getByTestId("settings-sidebar-scroll-body").hover();
await page.mouse.wheel(0, -pixels);
}
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size" });
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await scrollBody.hover();
await page.mouse.wheel(0, 2000);
await expect
.poll(() =>
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
)
.toBe(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
.toBeGreaterThan(0);
await scrollBody.hover();
await page.mouse.wheel(0, 1000);
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
return offset;
}
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
@@ -326,6 +413,25 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
function idleAgentReleaseSwitch(page: Page) {
return page
.getByTestId("host-page-idle-runtime-cleanup-card")
.getByRole("switch", { name: "Release idle agents" });
}
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
}
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
await toggle.click();
await expectIdleAgentRelease(page, enabled);
}
export async function openHostSection(
page: Page,
serverId: string,
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
if (settings.sidebarFooterItems) return;
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise<void> {
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
}
@@ -0,0 +1,99 @@
import { rename, mkdir, rmdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export async function openVoiceSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "voice");
await expectSettingsHeader(page, "Voice");
}
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
}
export async function expectSpeechSiteSummary(
page: Page,
label: SpeechSiteLabel,
summary: string,
): Promise<void> {
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
}
/** Opens the provider sheet for one use site and returns it. */
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
await speechSiteRow(page, label).click();
const sheet = page.getByRole("dialog");
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
return sheet;
}
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
return sheet.getByRole("button", { name: provider, exact: true });
}
export function apiKeyInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "API key" });
}
export function baseUrlInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "Base URL" });
}
export async function saveSpeechSite(sheet: Locator): Promise<void> {
await sheet.getByRole("button", { name: "Save", exact: true }).click();
}
export async function expectSheetClosed(page: Page): Promise<void> {
await expect(page.getByRole("dialog")).toHaveCount(0);
}
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const { config } = await client.getDaemonConfig();
const features = config.features as Record<string, Record<string, unknown>>;
const [feature, site] = siteId.split(".");
return features[feature][site] as Record<string, unknown>;
} finally {
await client.close().catch(() => undefined);
}
}
/** Puts a speech site back to the default provider so later tests start from a known state. */
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const [feature, site] = siteId.split(".");
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
} finally {
await client.close().catch(() => undefined);
}
}
/** Makes the daemon's config file unreadable for the duration of `run`. */
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
const configPath = path.join(paseoHome, "config.json");
const asidePath = `${configPath}.aside`;
await rename(configPath, asidePath);
await mkdir(configPath);
try {
await run();
} finally {
await rmdir(configPath);
await rename(asidePath, configPath);
}
}
+3
View File
@@ -15,6 +15,9 @@
}
},
"production": {
"ios": {
"image": "macos-tahoe-26.6-xcode-27.1"
},
"env": {
"APP_VARIANT": "production"
},
+12 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.13.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -198,6 +198,14 @@
],
"enableVideo": false
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -0,0 +1,84 @@
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) {
return "Agent";
}
return provider
.split(/[-_\s]+/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function resolveAgentLabel(title: string | null | undefined): string | null {
if (typeof title !== "string") {
return null;
}
const normalized = title.trim();
if (!normalized) {
return null;
}
if (normalized.toLowerCase() === "new agent") {
return null;
}
return normalized;
}
export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
const agent =
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
};
}),
);
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
}
export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
isTurnActive: boolean;
icon: PanelDescriptor["icon"];
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
};
}
@@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
holdReadingPosition() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
@@ -265,6 +265,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
@@ -316,6 +317,7 @@ describe("useChatOutline", () => {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
},
};
@@ -371,6 +373,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const livePrompt = {
@@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
getStreamItemMessageId,
hydrateStreamState,
type StreamItem,
type ToolCallItem,
@@ -13,7 +14,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
const runtime = {
paseo: {},
@@ -97,7 +98,11 @@ function pluginData(items: StreamItem[]): unknown[] {
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
}
const presentationOptions = { level: "overview" as const, isTurnActive: true };
const presentationOptions = {
display: "collapseToolCalls" as const,
expandedTurnHostIds: new Set<string>(),
isTurnActive: true,
};
function streamHarness(transform?: TimelineItemTransform) {
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
+64 -16
View File
@@ -1,3 +1,4 @@
import type { ChatDisplay } from "@/hooks/use-settings";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import {
@@ -5,23 +6,32 @@ import {
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
type ToolCallDetailProjection,
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
interface PresentationInput {
tail: StreamItem[];
head: StreamItem[];
transform: TimelineItemTransform | undefined;
level: ToolCallDetailLevel;
display: ChatDisplay;
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
}
/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
export interface StreamPresentation extends ToolCallDetailProjection {
turns: Omit<TurnCollapse, "tail">;
}
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
tail: [],
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
});
/** Source messages reach plugins before display projection. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -47,12 +57,47 @@ export function createStreamPresentation() {
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
let collapsedSource: StreamItem[] | undefined;
let collapsedIsLatestTurnOpen = false;
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] {
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
return (input: PresentationInput) => {
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): StreamPresentation {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
if (
!collapsed ||
collapsedSource !== projection.tail ||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
collapsedExpandedHostIds !== input.expandedTurnHostIds
) {
collapsed = collapseTurns({
tail: projection.tail,
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
return { ...projection, tail, turns };
}
return (input: PresentationInput): StreamPresentation => {
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
@@ -66,17 +111,20 @@ export function createStreamPresentation() {
const blocks = presentRows(item);
head.push(...blocks);
}
if (preparedTail !== historyRows || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, historyRows);
if (preparedTail !== historyRows || preparedLevel !== level) {
preparedHistory = prepareToolCallHistory(level, historyRows);
preparedTail = historyRows;
preparedLevel = input.level;
preparedLevel = level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
return collapse(
projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
}),
input,
);
};
}
@@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
holdReadingPosition: () => bottomAnchorController.detachByUser(),
};
viewportRef.current = handle;
return () => {
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -44,7 +44,6 @@ import {
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
import { getStreamItemMessageId } from "./presentation";
import { useScrollToMessage } from "./use-scroll-to-message.web";
interface CreateWebStreamStrategyInput {
@@ -1174,6 +1173,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdReadingPosition: () => {
cancelPendingStickToBottom();
setFollowOutput(false);
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
},
scrollToMessage,
};
viewportRef.current = handle;
@@ -1186,6 +1190,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
cancelPendingStickToBottom,
forceStickToBottom,
readingAnchor,
scheduleStickToBottom,
scrollToMessage,
viewportRef,
@@ -55,6 +55,8 @@ export interface StreamViewportHandle {
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
/** Stop following output so the rows on screen stay put while rows below them open or close. */
holdReadingPosition: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { collapseTurns, type CollapseTurnsInput } from "./model";
const at = new Date("2026-01-01T00:00:00.000Z");
function user(id: string): StreamItem {
return { kind: "user_message", id, text: id, timestamp: at };
}
function reply(id: string): StreamItem {
return { kind: "assistant_message", id, text: id, timestamp: at };
}
function thought(id: string): StreamItem {
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
}
function shell(id: string): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: at,
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls" },
},
},
};
}
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
return collapseTurns({
tail,
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
...options,
});
}
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("folds a finished turn's work into its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
reply("early"),
shell("c1"),
shell("c2"),
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
const result = collapse([
user("u1"),
shell("c1"),
reply("r1"),
shell("after"),
user("u2"),
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
const tail = [user("u1"), thought("t1"), reply("r1")];
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
});
it("counts every call a tool-call group row stands for", () => {
const group = [shell("c1"), shell("c2"), shell("c3")];
const result = collapse([user("u1"), group[0]!, reply("final")], {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
});
});
@@ -0,0 +1,122 @@
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The user message the turn answers. Its row carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
}
export interface TurnCollapse {
tail: StreamItem[];
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** Rows of expanded turns, which render inside the turn's indent rail. */
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
}
export interface CollapseTurnsInput {
tail: StreamItem[];
/** The latest turn is still producing output, so it stays unfolded. */
isLatestTurnOpen: boolean;
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
const EMPTY_IDS: ReadonlySet<string> = new Set();
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) {
if (item.kind === "assistant_message") messageCount += 1;
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { messageCount, tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
for (let index = end - 1; index >= start; index -= 1) {
if (tail[index]?.kind === "assistant_message") return index;
}
return -1;
}
/**
* Folds each answered turn into its user message: everything the agent did before its last
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Thoughts fold with the turn but are not counted.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const turnsByHostId = new Map<string, CollapsedTurn>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
let runStart = 0;
const flushRun = (runEnd: number) => {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
for (const item of folded) {
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
}
if (expanded) output.push(...folded);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (tail[index]?.kind !== "user_message") continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
}
flushRun(tail.length);
if (turnsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
};
}
return {
tail: output.length === tail.length ? tail : output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
};
}
@@ -0,0 +1,48 @@
import { memo, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
/**
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
* Pressing it opens the turn's rows beneath it.
*/
export const TurnSummaryLine = memo(function TurnSummaryLine({
turn,
onExpandedChange,
}: {
turn: CollapsedTurn;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const { messageCount, tools } = turn.summary;
const messageParts = useMemo(
() =>
messageCount > 0
? [
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
count: messageCount,
}),
]
: [],
[messageCount, t],
);
const label = useOverviewSummaryLabel(tools, messageParts);
const toggle = useCallback(
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
);
return (
<ExpandableBadge
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={turn.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
);
});
@@ -0,0 +1,12 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
// The view retains its last visible transcript. Selecting a stable value here
// also prevents incoming chunks from scheduling React work in a hidden panel.
// Revealing the panel reads the current tail directly from the model.
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
const active = useRetainedPanelActive();
return useSessionStore((state) =>
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
);
}
@@ -1,8 +1,7 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId } from "./presentation";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
import type { ScrollToMessageOccurrence } from "./strategy";
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getStreamItemMessageId } from "./presentation";
// Callers reveal a message, not a row: an assistant message is several block rows and
// the window has to open at the first of them.
+124 -16
View File
@@ -41,7 +41,7 @@ import {
type InlinePathTarget,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import type { PendingPermission } from "@/types/shared";
@@ -62,8 +62,10 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { IndentRail } from "@/components/indent-rail";
import { TurnSummaryLine } from "./turn-collapse/summary-line";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
@@ -148,9 +150,16 @@ function renderPendingPermissionsNode(input: {
);
}
/**
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
*/
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
turnIndent: TurnIndent;
strategy: TurnContentStrategy;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
@@ -171,7 +180,7 @@ function renderStreamItemWithTurnFooter(input: {
/>
) : null;
const content = (
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
{input.content}
</StreamItemWrapper>
);
@@ -333,7 +342,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) {
const { t } = useTranslation();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatDisplay = useSettings((settings) => settings.chatDisplay);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
@@ -357,6 +366,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
@@ -421,6 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
@@ -512,7 +523,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem,
level: toolCallDetailLevel,
display: chatDisplay,
expandedTurnHostIds,
isTurnActive,
}),
[
@@ -520,7 +532,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
effectiveStreamItems,
effectiveStreamHead,
transformTimelineItem,
toolCallDetailLevel,
chatDisplay,
expandedTurnHostIds,
isTurnActive,
],
);
@@ -651,6 +664,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[streamRenderStrategy],
);
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
// A turn opens below its summary line; keep that line under the reader's pointer.
viewportRef.current?.holdReadingPosition();
setExpandedTurnHostIds((previous) => {
if (previous.has(hostId) === expanded) return previous;
const next = new Set(previous);
if (expanded) {
next.add(hostId);
} else {
next.delete(hostId);
}
return next;
});
}, []);
// Chat find reveals a match inside a collapsed turn by opening the turn first.
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
if (hostId && !expandedTurnHostIds.has(hostId)) {
setTurnExpanded(hostId, true);
return true;
}
return revealLoadedHistory(messageId);
});
// Read through stable events so a collapse rerun does not change the renderer identity;
// rows whose turn state changed are revised through `historyRowRevision`.
const getCollapsedTurn = useStableEvent((hostId: string) =>
presentation.turns.turnsByHostId.get(hostId),
);
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
if (nestedItemIds.has(layoutItem.item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous);
@@ -665,7 +715,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -684,8 +735,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
);
const renderAssistantMessageItem = useCallback(
@@ -893,12 +961,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderStreamItemWithTurnFooter({
content,
layoutItem,
turnIndent: getTurnIndent(layoutItem),
strategy: streamRenderStrategy,
supportsTimelineCursor: supportsAgentForkContextCursor,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[
getTurnIndent,
handleForkAssistantTurn,
readOnly,
renderStreamItemContent,
@@ -1060,13 +1130,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds,
displayStateById: {
has: (id: string) =>
expandedToolCallGroupIds.has(id) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile,
}),
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
[
expandedToolCallGroupIds,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
],
);
const findItems = useMemo(
@@ -1080,7 +1162,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedHistory}
revealLoadedMessage={revealLoadedMessage}
visibleMessageIds={mountedMessageIds}
>
<ToolCallSheetProvider>
@@ -1763,15 +1845,41 @@ const permissionStyles = StyleSheet.create((theme) => ({
}));
interface StreamItemWrapperProps {
itemId: string;
gapBelow: number;
turnIndent: TurnIndent;
children: ReactNode;
}
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo(
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
[gapBelow],
() => [
stylesheet.streamItemWrapper,
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
],
[gapBelow, turnIndent],
);
return <View style={wrapperStyle}>{children}</View>;
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
switch (turnIndent) {
case "none":
return <View style={wrapperStyle}>{children}</View>;
case "openedHost":
return (
<View style={wrapperStyle}>
{children}
<IndentRail style={gapStyle} />
</View>
);
case "nested":
return (
<View style={wrapperStyle}>
<IndentRail style={gapStyle}>{children}</IndentRail>
</View>
);
case "lastNested":
return (
<View style={wrapperStyle}>
<IndentRail>{children}</IndentRail>
</View>
);
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
@@ -71,6 +72,7 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -507,7 +509,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
return (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
{surface}
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
</AppContainerLifetime>
);
}
@@ -758,6 +760,7 @@ function OpenProjectListener() {
}
function AppWithSidebar({ children }: { children: ReactNode }) {
useSettingsRouteEntry();
const pathname = usePathname();
const hosts = useHosts();
const storeReady = useStoreReady();
@@ -5,6 +5,7 @@ import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
@@ -87,11 +88,7 @@ function HostAgentReadyRouteContent() {
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
useDirectoryDemand(serverId);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
@@ -9,6 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -110,6 +111,10 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(params.open);
// A restored workspace with no open chat has nothing else demanding its host's directory on a
// phone, where the sidebar only demands while its panel is active. Without demand the deck
// waits on workspace hydration that never starts.
useDirectoryDemand(serverId);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -6,6 +6,7 @@ export {
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
useAssistantFileLinkEnvironmentBridge,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function useAssistantFileLinkEnvironmentBridge() {
const value = useContext(AssistantFileLinkResolverContext);
return useCallback(
(children: ReactNode) => (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
),
[value],
);
}
export function AssistantFileLinkResolverProvider({
client,
serverId,
@@ -2,9 +2,9 @@
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
@@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t
}
describe("useFileLink", () => {
// Lookups resolve after a test's last assertion; an unmounted hook cannot re-render
// once jsdom is torn down.
afterEach(() => {
cleanup();
});
it("returns the same object across no-op parent rerenders", () => {
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
const queryClient = createQueryClient();
+217 -135
View File
@@ -1,3 +1,5 @@
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
@@ -10,12 +12,25 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
import { StatusIcon } from "@/components/status-icon";
import { useWorkspaceStatus } from "@/workspace-status";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
@@ -69,9 +84,7 @@ import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
@@ -81,6 +94,7 @@ import {
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult,
} from "./results";
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
import { useWorkspaceFileSearch } from "./workspace-file-search";
const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
@@ -106,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId, workspaceId });
};
}
function createAgentResultAction(serverId: string, agentId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId });
};
}
/**
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
@@ -115,6 +143,9 @@ function useBuiltInRows(open: boolean): {
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const previousRows = useRef(
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
);
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
@@ -136,7 +167,22 @@ function useBuiltInRows(open: boolean): {
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
return useMemo(() => {
if (!open) return { workspaces: [], agents: [] };
if (!open) {
previousRows.current.clear();
return { workspaces: [], agents: [] };
}
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// row data so a directory update does not invalidate every mounted ResultRow.
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
row: T,
): T => {
const previous = previousRows.current.get(row.id);
if (previous) row.run = previous.run;
const stable = previous && equal(previous, row) ? (previous as T) : row;
nextRows.set(row.id, stable);
return stable;
};
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
@@ -147,23 +193,24 @@ function useBuiltInRows(open: boolean): {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
allWorkspaces.push({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
},
});
allWorkspaces.push(
preserve({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
serverId: host.serverId,
workspaceId: workspace.id,
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: createWorkspaceResultAction(host.serverId, workspace.id),
}),
);
}
}
}
@@ -174,22 +221,20 @@ function useBuiltInRows(open: boolean): {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
return preserve({
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
run: createAgentResultAction(agent.serverId, agent.id),
});
});
previousRows.current = nextRows;
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
}
@@ -227,8 +272,6 @@ interface CommandCenterState {
setQuery(query: string): void;
activeId: string | null;
rows: readonly CommandCenterListRow[];
results: readonly CommandCenterResult[];
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<EditingTextInputHandle | null>;
fileSearchLoading: boolean;
@@ -238,7 +281,10 @@ interface CommandCenterState {
key(key: string): boolean;
}
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
function useCommandCenterState(
listMetrics: CommandCenterListMetrics,
interaction: CommandCenterInteraction,
): CommandCenterState {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
@@ -248,8 +294,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const [query, setQueryState] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
const builtInSections = useBuiltInSections(open, query);
const {
entries: fileSearchEntries,
@@ -296,15 +341,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
),
[builtInSections, contributionSections, fileSections, listMetrics, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
// query is no longer meaningful. Reset it in the same update as the query rather than in an
// effect, so there is no intermediate render holding the stale selection.
const setQuery = useCallback((next: string) => {
setQueryState(next);
setActiveId(null);
}, []);
useLayoutEffect(() => {
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
}, [interaction, open, scope, query, projection, fileSearchLoading]);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
@@ -327,18 +366,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
return true;
}
if (pressed === "Enter") {
const selected = results.find((result) => result.id === resolvedActiveId);
const selected = results.find((result) => result.id === activeId);
if (!selected) return false;
select(selected);
return true;
}
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
if (results.length === 0) return false;
const direction = pressed === "ArrowDown" ? "next" : "previous";
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
return true;
return interaction.move(pressed === "ArrowDown" ? "next" : "previous");
},
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
[
close,
open,
projection.selectableResults,
query,
activeId,
scope,
select,
setScope,
interaction,
],
);
useEffect(() => {
@@ -348,8 +394,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
setQueryState("");
setActiveId(null);
if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement();
if (!element) return;
@@ -367,11 +411,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
scope,
clearScope: () => setScope(null),
query,
setQuery,
activeId: resolvedActiveId,
setQuery: interaction.setQuery,
activeId,
rows: projection.rows,
results: projection.selectableResults,
rowIndexByResultId: projection.rowIndexByResultId,
offsets: projection.offsets,
inputRef,
fileSearchLoading,
@@ -397,6 +439,7 @@ const ResultRow = memo(function ResultRow({
active,
onSelect,
}: ResultRowProps) {
recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]);
const press = useCallback(() => onSelect(result), [onSelect, result]);
const choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
@@ -430,7 +473,19 @@ const ResultRow = memo(function ResultRow({
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} query={query} />
{(state) => (
<ResultContent
result={result}
query={query}
backdrop={
active ||
state.pressed ||
Boolean((state as PressableStateCallbackType & { hovered?: boolean }).hovered)
? "surface1"
: "surface0"
}
/>
)}
</Pressable>
);
});
@@ -484,6 +539,72 @@ function SubtitleWithTimestamp({
);
}
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function AgentResultContent({
result,
query,
backdrop,
}: {
result: CommandCenterAgentResult;
query: string;
backdrop: SurfaceBackdrop;
}) {
const agent = result.agent;
const descriptor = useAgentDescriptor(
{ kind: "agent", agentId: agent.id },
{ serverId: agent.serverId },
);
const Icon = useMemo(
() => withUnistyles(descriptor.icon, providerIconMapping),
[descriptor.icon],
);
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
<Icon size={16} />
</View>
</StatusIcon>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
);
}
function WorkspaceResultIcon({
result,
backdrop,
}: {
result: CommandCenterWorkspaceResult;
backdrop: SurfaceBackdrop;
}) {
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
return (
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<WorkspaceProjectIcon project={result.project} />
</View>
</StatusIcon>
);
}
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
@@ -498,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu
);
}
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
function ResultContent({
result,
query,
backdrop,
}: {
result: CommandCenterResult;
query: string;
backdrop: SurfaceBackdrop;
}) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
@@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
);
}
if (result.kind === "agent") {
const agent = result.agent;
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
);
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
@@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View
style={styles.iconSlot}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<WorkspaceProjectIcon project={result.project} />
<View style={styles.iconSlot}>
<WorkspaceResultIcon result={result} backdrop={backdrop} />
</View>
<View style={styles.textContent}>
<QueryHighlightedText
@@ -669,62 +766,39 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
export function CommandCenter() {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const [interaction] = useState(() =>
createCommandCenterInteraction((offset) => {
const list = listRef.current ?? bottomSheetListRef.current;
list?.scrollToOffset({ offset, animated: false });
}),
);
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
interaction,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
onClose: state.close,
});
const revealActiveResult = useCallback(() => {
if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId);
if (index === undefined) return;
const { offset, visibleLength } = scrollMetricsRef.current;
if (visibleLength <= 0) return;
const rowTop = state.offsets[index];
const rowBottom = rowTop + state.rows[index].height;
let nextOffset: number | null = null;
if (rowTop < offset) nextOffset = rowTop;
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
if (nextOffset === null) return;
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
const boundedOffset = Math.max(0, nextOffset);
scrollMetricsRef.current.offset = boundedOffset;
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
}, [
showBottomSheet,
state.activeId,
state.offsets,
state.open,
state.rowIndexByResultId,
state.rows,
]);
useEffect(() => {
if (!state.open) {
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
return;
}
revealActiveResult();
}, [revealActiveResult, state.open]);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
revealActiveResult();
interaction.layout(event.nativeEvent.layout.height);
},
[revealActiveResult],
[interaction],
);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
interaction.scrolled(event.nativeEvent.contentOffset.y);
},
[interaction],
);
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
}, []);
useEffect(() => {
if (!showBottomSheet || !state.open) return;
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
@@ -790,6 +864,14 @@ export function CommandCenter() {
windowSize: 5,
onLayout: handleListLayout,
onScroll: handleListScroll,
onScrollBeginDrag: interaction.beginManualScroll,
onTouchStart: interaction.beginManualScroll,
...(isWeb
? {
onWheel: interaction.beginManualScroll,
onMouseDown: interaction.beginManualScroll,
}
: {}),
scrollEventThrottle: 16,
};
const keyPress = useCallback(
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { createCommandCenterInteraction } from "./interaction";
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
function results(ids = ["a", "b", "c", "d"]) {
return projectCommandCenterRows(
[
{
id: "files",
band: 0,
rank: 0,
title: "Files",
results: ids.map<CommandCenterFileResult>((id) => ({
id,
kind: "file",
filePath: id,
title: id,
subtitle: "",
run() {},
})),
},
],
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
);
}
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
const offsets: number[] = [];
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
model.update({
open: true,
scope: options.scope ?? null,
query: model.getSnapshot().query,
projection: results(ids),
loading,
});
publish();
if (options.measured !== false) model.layout(80);
return { model, offsets, publish };
}
describe("Command Center interaction", () => {
it("keeps manual scrolling through result refresh, removal, and resizing", () => {
const { model, offsets, publish } = setup();
model.beginManualScroll();
model.scrolled(100);
publish();
publish(["b", "c", "d"]);
model.layout(60);
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([]);
});
it("reveals only clipped keyboard selections and wraps around", () => {
const { model, offsets } = setup();
expect(model.move("next")).toBe(true);
expect(offsets).toEqual([20]);
model.scrolled(20); // The programmatic scroll callback is only a measurement.
model.move("next");
expect(offsets).toEqual([20, 60]);
model.move("previous");
expect(offsets).toEqual([20, 60]);
model.move("previous");
expect(offsets).toEqual([20, 60, 20]);
model.move("previous");
expect(model.getSnapshot().activeId).toBe("d");
expect(offsets).toEqual([20, 60, 20, 100]);
});
it("keyboard navigation takes control again after manual scrolling", () => {
const { model, offsets } = setup();
model.beginManualScroll();
model.scrolled(100);
model.move("next");
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([60]);
});
it("a new query reveals even when the active ID stays the same", () => {
const { model, offsets, publish } = setup();
model.scrolled(100);
model.setQuery("a");
expect(offsets).toEqual([]);
publish(["a", "b"]);
expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" });
expect(offsets).toEqual([20]);
model.scrolled(100);
publish(["a", "b"]);
expect(offsets).toEqual([20]);
});
it("an arrow command reveals a single result even when its ID cannot change", () => {
const { model, offsets, publish } = setup();
publish(["a"]);
model.scrolled(30);
model.move("next");
expect(offsets).toEqual([20]);
});
it("defers reveals until layout exists and consumes them only once", () => {
const { model, offsets, publish } = setup({ measured: false });
model.move("previous");
expect(offsets).toEqual([]);
model.layout(80);
expect(offsets).toEqual([100]);
model.scrolled(0);
publish();
model.layout(60);
expect(offsets).toEqual([100]);
});
it("manual interaction cancels an unmeasured request", () => {
const { model, offsets } = setup({ measured: false });
model.move("previous");
model.beginManualScroll();
model.layout(80);
expect(offsets).toEqual([]);
});
it("waits for scoped search results instead of revealing the previous query's rows", () => {
const { model, offsets, publish } = setup({ scope: "files" });
model.scrolled(100);
model.setQuery("new");
publish(["a", "b"], true);
expect(offsets).toEqual([]);
expect(model.getSnapshot().activeId).toBeNull();
publish(["b", "a"]);
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([20]);
});
it("manual interaction cancels a reveal awaiting search results", () => {
const { model, offsets, publish } = setup({ scope: "files" });
model.setQuery("new");
publish([], true);
model.beginManualScroll();
model.scrolled(100);
publish(["new"]);
expect(model.getSnapshot().activeId).toBe("new");
expect(offsets).toEqual([]);
});
it("empty searches complete without granting future passive results permission to scroll", () => {
const { model, offsets, publish } = setup();
model.setQuery("missing");
publish([]);
expect(model.move("next")).toBe(false);
expect(model.getSnapshot().activeId).toBeNull();
model.scrolled(100);
publish(["late"]);
expect(offsets).toEqual([]);
});
it("scope changes reset selection and request a reveal", () => {
const { model, offsets } = setup();
model.move("next");
model.scrolled(100);
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets).toEqual([20, 20]);
});
it("close discards the old query, metrics, and deferred reveal", () => {
const { model, offsets, publish } = setup({ measured: false });
model.setQuery("old");
publish();
model.move("previous");
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
model.layout(80);
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
expect(offsets).toEqual([]);
publish();
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets).toEqual([]);
});
});
@@ -0,0 +1,131 @@
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
import {
moveActiveResultId,
preserveActiveResultId,
type CommandCenterListProjection,
} from "./results";
interface Snapshot {
query: string;
activeId: string | null;
}
interface ResultsInput {
open: boolean;
scope: CommandCenterScope;
query: string;
projection: CommandCenterListProjection;
loading: boolean;
}
/** Selection persists; permission to move the viewport lasts for one user command. */
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
let snapshot: Snapshot = { query: "", activeId: null };
const listeners = new Set<() => void>();
let open = false;
let scope: CommandCenterScope = null;
let projection: CommandCenterListProjection | null = null;
let loading = false;
let revealPending = false;
let offset = 0;
let visibleLength = 0;
function publish(next: Snapshot) {
if (next.query === snapshot.query && next.activeId === snapshot.activeId) return;
snapshot = next;
for (const listener of listeners) listener();
}
function reveal() {
if (!open || !revealPending || !projection || visibleLength <= 0) return;
// Scoped file search keeps its previous entries while the next query is loading.
if (scope === "files" && loading) return;
if (!snapshot.activeId) {
if (!loading) revealPending = false;
return;
}
const index = projection.rowIndexByResultId.get(snapshot.activeId);
if (index === undefined) return;
const top = projection.offsets[index];
const bottom = top + projection.rows[index].height;
revealPending = false;
let nextOffset = offset;
if (top < offset) nextOffset = top;
if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength;
nextOffset = Math.max(0, nextOffset);
if (nextOffset === offset) return;
offset = nextOffset;
scrollToOffset(nextOffset);
}
return {
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
update(input: ResultsInput) {
const resetSelection = input.open && (!open || scope !== input.scope);
open = input.open;
scope = input.scope;
if (!open) {
projection = null;
revealPending = false;
offset = 0;
visibleLength = 0;
publish({ query: "", activeId: null });
return;
}
if (resetSelection) {
revealPending = true;
publish({ ...snapshot, activeId: null });
}
// A query command invalidates the old projection until React supplies its new results.
if (input.query !== snapshot.query) return;
projection = input.projection;
loading = input.loading;
if (scope !== "files" || !loading) {
publish({
...snapshot,
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
});
}
reveal();
},
setQuery(query: string) {
projection = null;
revealPending = true;
publish({ query, activeId: null });
},
move(direction: "next" | "previous"): boolean {
if (
!open ||
!projection ||
(scope === "files" && loading) ||
projection.selectableResults.length === 0
)
return false;
publish({
...snapshot,
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
});
revealPending = true;
reveal();
return true;
},
layout(height: number) {
visibleLength = height;
reveal();
},
scrolled(nextOffset: number) {
offset = nextOffset;
},
beginManualScroll() {
revealPending = false;
},
};
}
export type CommandCenterInteraction = ReturnType<typeof createCommandCenterInteraction>;

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