Compare commits

..
75 Commits
Author SHA1 Message Date
paseo-bot[bot]andMatteo Pietro Dazzi de50243885 Keep model-discovery errors away from picker edges (#8039)
* fix(app): pad the model picker empty state on all sides

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Refs #7513

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* fix(app): hide reasoning inside collapsed turns

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(server): exclude preemption within reconciliation callbacks

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

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

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

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

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

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

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

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

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

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

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

* test: run plugin responsiveness probe in serial integration suite

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

* test: distinguish compiler CPU blocking from runner scheduling

* test: classify the largest compiler CPU accounting window

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(server): preserve Windows worktree path spelling

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

(cherry picked from commit 870c969fdb)

* chore(release): cut 0.13.2

(cherry picked from commit d2941157a8)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #1532.

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

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

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

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

Closes #7869

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

* docs(release): add 0.13.1 hotfix notes

* chore(release): cut 0.13.1

---------

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

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

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

* docs: translate idle runtime cleanup configuration and provider contract

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

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

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

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

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

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

Closes #7869

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

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

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

Closes #3164.

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Closes #7440

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

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

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

Cover draft and existing-chat model selectors and the compact tab switcher
through Settings, Back, and reopening. The desktop regressions also verify
Escape dismissal.
2026-10-24 07:25:30 +00:00
paseo-ai[bot] e01a38d943 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-24 06:13:55 +00:00
516 changed files with 34662 additions and 9292 deletions
+28
View File
@@ -169,6 +169,34 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: | run: |
set -euo pipefail set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL") build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+145
View File
@@ -85,6 +85,151 @@
"max-nested-callbacks": ["error", { "max": 3 }] "max-nested-callbacks": ["error", { "max": 3 }]
}, },
"overrides": [ "overrides": [
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
{
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
"rules": {
"paseo/no-sync-io": [
"error",
{
"allow": {
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
"executable-resolution/executable-resolution.ts": ["existsSync"],
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
"executable-resolution/windows.ts": ["readdirSync"],
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
"server/agent/providers/claude/agent.ts": ["existsSync"],
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
"server/agent/providers/claude/subagents/workflow-output.ts": [
"closeSync",
"openSync",
"readSync"
],
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
"server/agent/providers/omp/agent.ts": ["existsSync"],
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
"server/agent/providers/pi/agent.ts": [
"existsSync",
"mkdtempSync",
"readFileSync",
"rmSync",
"writeFileSync"
],
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
"server/config.ts": ["existsSync"],
// Startup identity: load or create the daemon keypair before accepting connections.
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
"server/host-name.ts": ["execFileSync"],
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
"server/hub/relationship-controller.ts": [
"existsSync",
"readFileSync",
"renameSync",
"rmSync"
],
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
"server/local-credential.ts": ["readFileSync"],
// Startup logger construction: create the log directory before opening the logging destination.
"server/logger.ts": ["mkdirSync"],
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
// Module initialization: the package version is resolved once and cached at import.
"server/package-version.ts": ["existsSync", "readFileSync"],
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
"server/persisted-config.ts": ["existsSync", "readFileSync"],
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
"server/pid-lock.ts": ["readFileSync"],
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
"server/plugins/builtin/index.ts": ["existsSync"],
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
// Build child process: select the esbuild binary for the isolated plugin compiler.
"server/plugins/compiler-esbuild.ts": ["existsSync"],
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
"server/private-files.ts": [
"chmodSync",
"mkdirSync",
"realpathSync",
"renameSync",
"rmSync",
"writeFileSync"
],
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
"server/push/token-store.ts": ["existsSync", "readFileSync"],
// Startup identity: load or create the server id before accepting connections.
"server/server-id.ts": ["existsSync", "readFileSync"],
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
// Speech child process: native addon discovery and patchelf run outside the daemon.
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
"existsSync",
"spawnSync"
],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
"server/web-ui.ts": ["readFileSync", "statSync"],
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
"server/workspace-registry-bootstrap.ts": ["statSync"],
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
"terminal/agent-hooks/agent-hook-installer.ts": [
"existsSync",
"readFileSync",
"rmSync"
],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
"terminal/terminal.ts": [
"accessSync",
"chmodSync",
"existsSync",
"mkdirSync",
"readFileSync",
"statSync"
],
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
"utils/path.ts": ["realpathSync"]
}
}
]
}
},
{
"files": [
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"packages/server/src/**/test-utils/**",
"packages/server/src/server/daemon-e2e/**",
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
],
"rules": { "paseo/no-sync-io": "off" }
},
{ {
"files": [ "files": [
"plugins/*-provider/server/**/*.ts", "plugins/*-provider/server/**/*.ts",
+60
View File
@@ -1,5 +1,65 @@
# Changelog # Changelog
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
### Added
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### Improved
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26 ## 0.13.2 - 2026-10-26
### Fixed ### Fixed
+60
View File
@@ -1,5 +1,65 @@
# 更新日志 # 更新日志
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### 改进
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26 ## 0.13.2 - 2026-10-26
### 修复 ### 修复
+49 -3
View File
@@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the 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. 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 Idle resumable runtimes are collected after one hour without activity, checked once a minute.
action such as archive, replacement, reload, workspace teardown, or daemon shutdown. 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 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 inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
@@ -242,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. - **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. 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.
+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: { providers: {
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string } local: { modelsDir: string }
}, },
agents: { agents: {
@@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
pluginsEnabled: boolean, pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>, plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: { features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, dictation: { enabled, stt: SpeechSttConfig },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
}, },
log: { log: {
level, format, level, format,
@@ -353,42 +347,27 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload. 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 | 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
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider | settings. A provider-less site becomes local; provider names are lower-cased, and
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | fields ignored by the selected speech provider are discarded. The next save writes only the
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | 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: `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)
```json and report effective env sources in `envOverrides`. A site patch that changes
{ `provider` replaces the entire object. The same or omitted `provider` merges fields
"providers": { into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
"openai": { options from the previous variant.
"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`
--- ---
+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: `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`. 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 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. The whitespace is the design.
--- ---
+53
View File
@@ -273,6 +273,26 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency `PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them. measurements so profiling overhead does not contaminate them.
For diff layout, two benchmarks share one corpus
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
shared document model with the web and native measurers replaced by counters, so it reports how
many strings each backend would have to shape, which is the cost that survives the move to canvas
or Skia:
```bash
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
```
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
React commit time for cold and warm toggles, then scroll frame times in both modes:
```bash
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
```
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input. Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer, That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
@@ -542,6 +562,39 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module` (`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed. (`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## Dependency overrides and workspace tooling
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
`react-native`, …). npm does not apply those overrides to packages it places under a
workspace's own `node_modules`. When `expo-module-scripts` was declared in
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
where the overrides apply; workspace scripts still find the binary through the root
`node_modules/.bin`.
## Dependency signature audit
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
signature and, for packages published with provenance, fetches
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
for a year. An edge that cached it in the seconds before the registry attached the publish
attestation keeps serving a document with only the provenance bundle, and npm fails with:
```
npm error code EMISSINGSIGNATUREKEY
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
```
The same package's registry signature verified a moment earlier with the same keys, so the
key list is intact. The failure names one package, repeats only for that package, and depends
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
## ACP provider catalog versions ## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`, The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+6
View File
@@ -94,6 +94,12 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`, `_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to `_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
`.dev/agent-device-artifacts/composer-keyboard-ios`. `.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 The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
+22 -1
View File
@@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete. catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host). query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -430,8 +439,15 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
@@ -498,6 +514,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 See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics. 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 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 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 daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
+2 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart. The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often. Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
Two contracts follow from it. Two contracts follow from it.
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape. - **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema). - Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that. Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
## Client capability ownership ## Client capability ownership
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+25 -10
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are: The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an > A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable > agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination. > through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
@@ -44,7 +44,21 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains Opening or resuming an agent fetches the latest available turns with a soft target of 40
projected items. Backward pages and prompt-jump windows also include whole turns, including
all currently available rows of an active run. There is no per-turn cap: a large run can
exceed the target. Local history mounting uses the same boundaries so revealing older rows
does not change the collapse state of a turn already displayed.
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
Reading history remains available on older hosts, whose pages can still split turns. Forward
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward. user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
@@ -149,12 +163,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines. history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
instead of replaying every missed page. Live gap recovery still pages forward until current. instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that range and keeps a display-only tail of complete available turns. The item target is soft;
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap. authority, so the owner uses the ordinary bounded `tail` bootstrap.
@@ -238,11 +253,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement. remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle Every canonical run owns a visible turn, including heartbeats and agent notifications.
notifications project into tool rows, so they do not create a human-message boundary. Legacy system Notifications delivered during a run stay inside it; batched notifications share the run
prompts, including schedule injections, remain hidden. One visible response can span several canonical that starts or accepts them. Received messages retain sender attribution, while lifecycle
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact notifications remain visible events. Collapse, timing and locally mounted history share
fork positions retain the canonical `turnId` boundaries. these boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
+1 -1
View File
@@ -1 +1 @@
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+2357 -4098
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.2", "version": "0.14.0-beta.1",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -131,6 +131,7 @@
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "44.2.0", "electron": "44.2.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"knip": "^5.82.1", "knip": "^5.82.1",
@@ -18,7 +18,9 @@ import {
expectComposerVisible, expectComposerVisible,
cancelAgent, cancelAgent,
composerLocator, composerLocator,
expectQueuedMessageButton,
fillComposerDraft, fillComposerDraft,
holdSendButton,
sendDraftToQueue, sendDraftToQueue,
startRunningMockAgent, startRunningMockAgent,
submitMessage, submitMessage,
@@ -51,6 +53,7 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork"; import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import { import {
scrollTimelineToNewestLoadedEdge, scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable, scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination"; } from "../support/helpers/timeline-pagination";
import { import {
@@ -984,7 +987,7 @@ test.describe("Agent message submission", () => {
} }
}); });
test("keeps one prompt when its provider echo crosses the bounded tail", async ({ test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
page, page,
}, testInfo) => { }, testInfo) => {
const workspace = await seedWorkspace({ const workspace = await seedWorkspace({
@@ -1001,8 +1004,10 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page); await expectVisibleAgentSurfacesIdle(page);
await page.reload(); await page.reload();
await expect(submittedPrompt).toHaveCount(0); // The complete turn arrives on reload; scrolling mounts its oldest rows
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt); // without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(1); await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally { } finally {
@@ -1290,6 +1295,49 @@ test.describe("Agent message submission", () => {
} }
}); });
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => { test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000); test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude"); await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -27,6 +27,10 @@ import {
seedRealisticOlderTimeline, seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration, sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend, expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard, userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart, userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination"; } from "../support/helpers/timeline-pagination";
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
} }
}); });
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({ test("loads older history when the user navigates to history start with the keyboard", async ({
page, page,
}) => { }) => {
@@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function readFileLinkTooltip(page: Page): Promise<void> {
const link = page.getByRole("link", { name: "the guide", exact: true });
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("guide.md");
}
async function leaveFileLink(page: Page): Promise<void> {
await page.mouse.move(5, 5);
await expect(page.getByRole("tooltip")).toHaveCount(0);
}
async function openLinkedFile(page: Page): Promise<void> {
await page.getByRole("link", { name: "the guide", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
async function returnToAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
await leaveFileLink(page);
}
async function switchToFileTab(page: Page): Promise<void> {
await page.getByRole("button", { name: "guide.md", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "tooltip-5811-",
title: "Tooltip regression",
featureValues: {
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await writeFile(
path.join(agent.cwd, "guide.md"),
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
);
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await readFileLinkTooltip(page);
await leaveFileLink(page);
await readFileLinkTooltip(page);
await openLinkedFile(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await switchToFileTab(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await leaveFileLink(page);
} finally {
await agent.cleanup();
}
});
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> { async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ settingsKey, fontSize }) => { ({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) { const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
if (settings.codeFontSize !== undefined) {
return; return;
} }
localStorage.setItem( localStorage.setItem(
settingsKey, settingsKey,
JSON.stringify({ JSON.stringify({
...settings,
theme: "dark", theme: "dark",
sendBehavior: "interrupt", sendBehavior: "interrupt",
serviceUrlBehavior: "ask", serviceUrlBehavior: "ask",
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectSubagentRowVisible(page, child.id); 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 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 page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title); 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. // 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. // 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 }> { 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"); const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter); const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1); expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1); expect(centroid.y).toBeLessThan(-1);
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test"; import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
@@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => {
await repo.cleanup().catch(() => undefined); await repo.cleanup().catch(() => undefined);
} }
}); });
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
test.setTimeout(300_000);
const corpus = await createPullRequestCorpusRepository();
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: corpus.repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create wrap performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await instrumentDiffLatency(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
const openStartedAt = performance.now();
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
const open = {
readyMs: Math.round(performance.now() - openStartedAt),
...(await readDiffDocumentRenders(page)),
};
const toggles: WrapToggleMeasurement[] = [];
for (const name of [
"wrap on (cold)",
"wrap off (cold)",
"wrap on (warm)",
"wrap off (warm)",
]) {
toggles.push(await measureWrapToggle(page, name));
}
const unwrappedScroll = await continuouslyScrollCanvas(page);
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
const wrappedScroll = await continuouslyScrollCanvas(page);
const report = {
scenario: "wrap-toggle",
sourceFiles: corpus.fileCount,
sourceLines: corpus.lineCount,
open,
toggles,
scroll: {
unwrapped: summarizeScroll(unwrappedScroll),
wrapped: summarizeScroll(wrappedScroll),
},
};
await attachPerformanceReport(testInfo, report);
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
const [wrapOn, wrapOff] = toggles;
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
} finally {
await client.close().catch(() => undefined);
await corpus.repo.cleanup().catch(() => undefined);
}
});
}); });
interface WrapToggleMeasurement {
name: string;
/** Pointer down on the toolbar button to two painted frames after the document resized. */
latencyMs: number;
scrollHeightBefore: number;
scrollHeightAfter: number;
longestTaskMs: number;
longTaskTotalMs: number;
diffDocumentCommits: number;
diffDocumentRenderMs: number;
}
interface LongTask {
startTime: number;
duration: number;
}
interface DiffLatencyScope {
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
__PASEO_RESET_RENDER_PROFILE__?: () => void;
__PASEO_LONG_TASKS__?: LongTask[];
__PASEO_WRAP_TOGGLE_AT__?: number;
}
async function createPullRequestCorpusRepository() {
const files = pullRequestCorpus();
const repo = await createTempGitRepo("diff-wrap-perf-", {
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
});
for (const file of files) {
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
}
return {
repo,
fileCount: files.length,
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
};
}
async function instrumentDiffLatency(page: Page): Promise<void> {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
scope.__PASEO_LONG_TASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
});
}
async function readDiffDocumentRenders(
page: Page,
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === "DiffDocument",
);
return {
diffDocumentCommits: samples.length,
diffDocumentRenderMs: Math.round(
samples.reduce((total, sample) => total + sample.actualDuration, 0),
),
};
});
}
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
const scroll = page.getByTestId("git-diff-scroll");
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
await page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RESET_RENDER_PROFILE__?.();
scope.__PASEO_LONG_TASKS__ = [];
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
document.addEventListener(
"pointerdown",
() => {
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
},
{ capture: true, once: true },
);
});
await page.getByTestId("changes-toggle-wrap-lines").click();
const measured = await scroll.evaluate(async (element, before) => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const deadline = performance.now() + 240_000;
while (element.scrollHeight === before && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const doneAt = performance.now();
// Long task entries are delivered after the task ends; give the observer a turn.
await new Promise((resolve) => setTimeout(resolve, 50));
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
);
return {
latencyMs: Math.round(doneAt - startedAt),
scrollHeightAfter: element.scrollHeight,
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
};
}, scrollHeightBefore);
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
}
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
const { frameSignatures: _frameSignatures, ...summary } = report;
return summary;
}
async function measureAdditionalPerformanceScenario( async function measureAdditionalPerformanceScenario(
page: Page, page: Page,
cdp: CDPSession, cdp: CDPSession,
@@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page
}); });
}); });
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> { async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name); const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true }); await page.screenshot({ path: outputPath, fullPage: true });
@@ -1,29 +1,90 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import { import {
focusTerminalSurface, focusTerminalSurface,
typeInTerminal, typeInTerminal,
getTerminalBufferText, getTerminalBufferText,
} from "../support/helpers/terminal-perf"; } from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures"; import { metroTest as test, expect, type Page } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { import {
expectTimelinePromptVisible, expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
holdOlderHistoryPages, holdOlderHistoryPages,
openAgentTimeline, openAgentTimeline,
scrollThroughOlderHistoryPages, expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination"; } from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
page, page,
}) => { }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>(); const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => { page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => { socket.on("framesent", ({ payload }) => {
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
observationSockets.add(socket); observationSockets.add(socket);
}); });
}); });
const workspace = await seedWorkspace({ const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
repoPrefix: "timeline-old-daemon-pagination-", repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression", title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
}); });
const agent: MockAgentWorkspace = { const { agent, serverId } = published;
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try { try {
for (let index = 0; index < 40; index += 1) { await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.sendAgentMessage( await agent.client.waitForFinish(agent.agentId, 15_000);
agent.agentId, await published.seedBrowser(page);
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId); await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible( await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1); expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => { await test.step("reconnect the browser and reload the old daemon history", async () => {
await daemon.restart(); observationSockets.clear();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); await page.reload();
await expectComposerEditable(page); await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await submitMessage( await expect.poll(() => observationSockets.size).toBe(1);
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
}); });
await test.step("open a terminal and receive its output", async () => { await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull(); expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id; const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page); await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 }) .poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output"); .toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId); await agent.client.killTerminal(terminalId);
}); });
} finally { } finally {
await agent.cleanup(); await published.close();
await daemon.close(); }
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
} }
}); });
@@ -1,4 +1,5 @@
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { import {
withTimelinePlugin, withTimelinePlugin,
viewInlineThinkingExample, viewInlineThinkingExample,
@@ -6,6 +7,8 @@ import {
interactWithStreamingCard, interactWithStreamingCard,
expectWholeCompletedCard, expectWholeCompletedCard,
expectBothConsecutiveTools, expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline"; } from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) { for (const width of [1100, 390]) {
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
page, page,
}, info) => { }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => { await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent); await requestPluginTimeline(agent);
await interactWithStreamingCard(page); await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000); await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page); await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff. // Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page); await expectWholeCompletedCard(page);
}); });
}); });
@@ -1,5 +1,7 @@
import path from "node:path";
import type { Locator } from "@playwright/test"; import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
} }
}); });
}); });
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
panel.getByText("Start chatting with this agent...", { exact: true }), panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0); ).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( await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 }); ).toBeVisible({ timeout: 60_000 });
@@ -1,6 +1,6 @@
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import path from "node:path"; 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 { z } from "zod";
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream"; 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 SUCCESS_QUESTION = "What success criteria should we use?";
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?"; 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 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.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("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000); 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 { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app"; import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -25,6 +29,8 @@ import {
removeHostFromHostPage, removeHostFromHostPage,
addDirectHostFromSettings, addDirectHostFromSettings,
expectHostOnlineWithoutError, expectHostOnlineWithoutError,
expectIdleAgentRelease,
setIdleAgentRelease,
} from "../support/helpers/settings"; } from "../support/helpers/settings";
test.describe("Settings host page", () => { 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 ({ test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page, page,
}) => { }) => {
@@ -6,7 +6,10 @@ import {
addDirectHostFromSettings, addDirectHostFromSettings,
clickSettingsBackToWorkspace, clickSettingsBackToWorkspace,
openSettingsHostSection, openSettingsHostSection,
openSettingsSection,
selectSettingsHost,
} from "../support/helpers/settings"; } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
await openSettings(page); await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); 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 { } finally {
await remoteWorkspace.cleanup().catch(() => undefined); await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().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 { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { import {
closeCompactSettings, closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection, openSettingsSection,
expectSettingsHeader, expectSettingsHeader,
openAddHostFlow, openAddHostFlow,
@@ -43,6 +52,8 @@ import {
} from "../support/helpers/settings"; } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions"; 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( async function openWorkspace(
page: import("@playwright/test").Page, page: import("@playwright/test").Page,
@@ -52,7 +63,167 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible(); 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.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 }) => { test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
@@ -77,6 +248,28 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/); 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 }) => { test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page); await verifyLegacyHostSettingsRedirect(page);
@@ -147,6 +340,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => { test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } }); 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 ({ test("opens compact app and host details and returns through the settings list", async ({
page, page,
}) => { }) => {
@@ -223,6 +452,15 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsHostPickerLabel(page, secondaryHostLabel); await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections"); 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 ({ test("removing the last active host returns to welcome after settings closes", async ({
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
} }
}); });
}); });
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
@@ -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 }) => { test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3"; const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({ const workspace = await seedMockAgentWorkspace({
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
"@paseo:app-settings", "@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(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
"@paseo:app-settings", "@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }), JSON.stringify({ chatDisplay: "collapseToolCalls" }),
); );
}); });
const agent = await seedMockAgentWorkspace({ const agent = await seedMockAgentWorkspace({
@@ -0,0 +1,558 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
},
},
},
});
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.\n" + "Keep the previous response visible.\n".repeat(40),
);
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;
});
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
return rows
.slice(start + 1, end)
.flatMap((row) =>
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
(group) => group.textContent ?? "",
),
);
});
}
async function expectCompactMessageToggle(prompt: Locator, label: string) {
const button = prompt.getByRole("button", { name: label, exact: true });
const text = prompt.getByText(label, { exact: true });
await expect
.poll(
async () => {
const control = (await button.boundingBox())!;
const glyphs = (await text.boundingBox())!;
return control.height - glyphs.height;
},
{ message: "The inline toggle adds no vertical space beyond its text" },
)
.toBe(0);
}
async function expandAndCollapseLongPrompt(page: Page) {
const prompt = page.getByTestId("user-message").last();
const before = await prompt.boundingBox();
const text = prompt.locator('[data-message-text="true"]');
const messageLeft = (await text.boundingBox())!.x;
await expect
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expectCompactMessageToggle(prompt, "Show more");
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
await expect
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expect
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
.toBeGreaterThan(before!.height);
await expectCompactMessageToggle(prompt, "Show less");
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
await prompt.scrollIntoViewIfNeeded();
const bounds = (await prompt.boundingBox())!;
const body = (await text.boundingBox())!;
await page.screenshot({
path: "/tmp/message-toggle-aligned.png",
clip: {
x: body.x - 16,
y: bounds.y,
width: bounds.x + bounds.width - body.x + 16,
height: bounds.height,
},
});
}
test.describe("Turn collapse", () => {
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expandAndCollapseLongPrompt(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^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.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
});
test("received messages show their sender and open that agent", async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "sender-attribution-",
title: "Implementation",
});
try {
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
await expect(attribution).toBeVisible();
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
"I reviewed the turn-grouping changes.",
);
const labelColor = await attribution
.getByText("Sent by Reviewer", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
interface MessagePreviewWatch {
/** Frames after the watch started on which the fade or "Show more" control was missing. */
framesWithoutClip: number[];
/** Frames on which the fade was a different DOM node than when the watch started. */
framesRemounted: number[];
}
declare global {
interface Window {
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
}
}
async function seedReceivedReviewWithToolTurn() {
const recipient = await seedMockAgentWorkspace({
repoPrefix: "preview-clip-",
title: "Implementation",
});
const sender = await recipient.client.createAgent({
provider: "mock",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
modeId: "load-test",
model: "e2e-fast-stream",
});
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
await recipient.client.sendAgentMessage(
recipient.agentId,
"Review complete: the regression is red on the baseline and green with the fix.\n" +
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
"Now emit 7 tool agent stream updates",
{ sourceAgentId: sender.id },
);
return recipient;
}
/** Samples the received message's bubble on every animation frame until stopped. */
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
await page.evaluate((label) => {
const findBubble = () =>
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
row.textContent?.includes(label),
) ?? null;
const initialFade = findBubble()?.querySelector("svg") ?? null;
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
const framesWithoutClip: number[] = [];
const framesRemounted: number[] = [];
let frame = 0;
let running = true;
const sample = () => {
if (!running) return;
const bubble = findBubble();
const fade = bubble?.querySelector("svg") ?? null;
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
else if (fade !== initialFade) framesRemounted.push(frame);
frame += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
window.__paseoMessagePreviewWatch = {
stop() {
running = false;
return { framesWithoutClip, framesRemounted };
},
};
}, senderLabel);
}
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
// Give the opened turn's layout a few frames to settle before judging.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
expect(
watch.framesWithoutClip,
"the preview fade and Show more control stay on every frame",
).toEqual([]);
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
}
// Opening a turn must not rebuild its host row: the preview above the summary loses its
// measured height when remounted, which drops the fade and "Show more" for a frame.
test("opening a turn keeps the received message above it clipped on every frame", async ({
page,
}) => {
await useChatDisplay(page, "collapseTurns");
const recipient = await seedReceivedReviewWithToolTurn();
try {
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
timeout: 30_000,
});
const bubble = page
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 7 commands", exact: true })
.click();
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
await expectReceivedMessagePreviewUndisturbed(page);
} finally {
await recipient.cleanup();
}
});
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
const label = button.querySelector('[dir="auto"]');
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
return {
labelRight: label.getBoundingClientRect().right,
bubbleRight: bubble.getBoundingClientRect().right,
};
});
expect(
Math.abs(edges.labelRight - edges.bubbleRight),
"sender and bubble share the right edge",
).toBeLessThanOrEqual(1);
}
async function seedWholeTurnHistory() {
const agent = await seedMockAgentWorkspace({
repoPrefix: "whole-turn-history-",
title: "Whole turns",
model: "e2e-fast-stream",
});
for (const prompt of [
...Array.from(
{ length: 25 },
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
),
"Older long turn: emit 60 tool agent stream updates",
"Latest long turn: emit 120 tool agent stream updates",
]) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
async function useThinkingDisplay(
page: Page,
autoExpandReasoning: boolean,
chatDisplay: ChatDisplay,
) {
await page.addInitScript(
(settings) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({
...settings,
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
}),
);
},
{ autoExpandReasoning, chatDisplay },
);
}
for (const autoExpandReasoning of [false, true]) {
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
const agent = await seedMockAgentWorkspace({
repoPrefix: "thinking-group-",
title: "Thinking with tools",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
await expectThinkingPresentation(page, autoExpandReasoning);
await switchToCollapsedTurns(page);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toBeVisible();
await expect(summary).not.toContainText(/thought/i);
await expect(reasoning).toHaveCount(0);
await summary.click();
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await openOneToolOutput(page);
await summary.click();
await expect(reasoning).toHaveCount(0);
} finally {
await agent.cleanup();
}
});
}
async function openOneToolOutput(page: Page) {
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
await expect(output).toHaveCount(0);
const command = page
.getByTestId("tool-call-badge")
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
.first();
await command.getByRole("button").first().click();
await expect(output.first()).toBeVisible();
await command.getByRole("button").first().click();
await expect(output).toHaveCount(0);
}
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
if (autoExpandReasoning) {
await expect(reasoning).toBeVisible();
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
} else {
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
}
}
async function switchToCollapsedTurns(page: Page) {
await page.evaluate(() => {
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
});
await page.reload();
}
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
page,
}) => {
test.setTimeout(90_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "heartbeat-turn-",
title: "Heartbeat turns",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
const schedules = agent.client as unknown as Pick<
DaemonClient,
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
>;
let scheduleId: string | undefined;
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summaries = page.getByTestId("turn-summary");
await expect(summaries).toHaveCount(1);
const previousSummary = await summaries.first().innerText();
const previousReplyId = await page
.getByTestId("assistant-message")
.last()
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
const created = await schedules.scheduleCreate({
name: "Check the completed work",
prompt: "Inspect the scroll container again",
cadence: { type: "cron", expression: "0 0 1 1 *" },
target: { type: "self", agentId: agent.agentId },
runOnCreate: false,
});
expect(created.error).toBeNull();
scheduleId = created.schedule!.id;
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
expect(fired.error).toBeNull();
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
).toBeVisible();
await expect(summaries.first()).toHaveText(previousSummary);
const previousReply = page
.locator(`[data-message-id="${previousReplyId}"]`)
.getByTestId("assistant-message");
await expect(previousReply).toBeVisible();
await expect(previousReply).toContainText("(end of synthetic stream)");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(summaries).toHaveCount(2);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
} finally {
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
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");
}
});
@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible, expectWorkspaceTabVisible,
openWorkspaceWithAgents, openWorkspaceWithAgents,
} from "../support/helpers/archive-tab"; } from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout"; import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher"; import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => { test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({ test("keeps an offline notification on the agent route until the host connects", async ({
page, page,
}) => { }) => {
@@ -6,6 +6,7 @@ for await (const line of lines) {
const command = JSON.parse(line); const command = JSON.parse(line);
let data = {}; let data = {};
if (command.type === "get_available_models") { if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = { data = {
models: [ models: [
{ {
@@ -28,7 +28,10 @@ function contains(outer, inner) {
* while suggestions are open, so its callers read the composer from `reference`: a * while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input. * snapshot in the same keyboard state with an empty input.
*/ */
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) { export function resolveAutocompletePlacement(
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes; const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect; const popover = findById(nodes, "composer-autocomplete-popover").rect;
@@ -40,6 +43,10 @@ export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop,
const keyboardY = const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover); 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) { if (popoverBottom > composer.y + 1) {
throw new Error( throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
+136 -25
View File
@@ -14,7 +14,8 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
const serverId = env("SERVER_ID"); const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID"); const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID"); const workspaceId = env("WORKSPACE_ID");
if (!serverId || !agentId || !workspaceId) { const scenario = env("SCENARIO", "all");
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error( throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
); );
@@ -22,7 +23,12 @@ if (!serverId || !agentId || !workspaceId) {
const draftId = env("DRAFT_ID", "composer-keyboard"); const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios"); const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro"); const device = env("DEVICE", "iPhone 16 Pro");
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"); 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"; const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after // Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match. // tapping the best match.
@@ -35,7 +41,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true }); fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) { function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], { return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
encoding: "utf8", encoding: "utf8",
maxBuffer: 64 * 1024 * 1024, maxBuffer: 64 * 1024 * 1024,
}); });
@@ -77,7 +83,7 @@ async function settledPlacement(name, keyboard) {
} }
function assertPlacement(name, snap, expected) { function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected }); const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
process.stdout.write( process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height, placement.popover.y + placement.popover.height,
@@ -123,21 +129,34 @@ async function clearInput(name) {
)?.rect; )?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key"); if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length; const length = composerText(keyboard).length;
ad( for (let remaining = length + 2; remaining > 0; remaining -= 100) {
"press", ad(
String(Math.round(deleteKey.x + deleteKey.width / 2)), "press",
String(Math.round(deleteKey.y + deleteKey.height / 2)), String(Math.round(deleteKey.x + deleteKey.width / 2)),
"--count", String(Math.round(deleteKey.y + deleteKey.height / 2)),
String(length + 2), "--count",
"--interval-ms", String(Math.min(remaining, 100)),
"40", "--interval-ms",
); "40",
);
}
} }
// The accessibility value drops a trailing space. // The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function runCase(host, kind, { text, expected, inserted }) { 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}`; const name = `${host}-${kind}`;
await clearInput(name); await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`)); await dismissKeyboard(snapshot(`${name}-empty`));
@@ -151,31 +170,32 @@ async function runCase(host, kind, { text, expected, inserted }) {
await clearInput(`${name}-settled`); await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text); ad("type", text);
assertPlacement( const opened = assertPlacement(
`${name} opened after keyboard settled`, `${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true), await settledPlacement(`${name}-open`, true),
expected, expected,
); );
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`)); await dismissKeyboard(snapshot(`${name}-before-close`));
assertPlacement( const closed = assertPlacement(
`${name} keyboard closed`, `${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false), await settledPlacement(`${name}-closed`, false),
expected, expected,
); );
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`)); focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement( const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`, `${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true), await settledPlacement(`${name}-reopened`, true),
expected, expected,
); );
ad("press", String(reopened.x), String(reopened.y)); await selectRow(name, reopened, inserted);
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
} }
// Drafts list commands for their selected provider, and a fresh install selects none. // Drafts list commands for their selected provider, and a fresh install selects none.
@@ -216,13 +236,104 @@ async function runHost(host) {
await dismissKeyboard(snapshot(`${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 = [ const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`], ["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`], ["new-workspace", `paseo://new?serverId=${serverId}`],
]; ];
for (const [host, url] of hosts) { for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", "--device", device); ad("open", url, "--platform", "ios", ...deviceArgs);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host); await runHost(host);
} }
@@ -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>
);
}
+5
View File
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts])); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings"); localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); 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, daemon: testDaemon,
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
return delayAgentTimelineResponse(page, agentId, "before"); return delayAgentTimelineResponse(page, agentId, "before");
} }
export interface OlderTimelinePagesOptions {
daemonPort?: number;
/**
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
*/
itemPagedHost?: boolean;
}
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
const envelope = parseWebSocketJson(message) as {
message: { payload: { features?: Record<string, unknown> } };
};
delete envelope.message.payload.features?.wholeTurnTimeline;
return JSON.stringify(envelope);
}
export async function holdAgentOlderTimelinePages( export async function holdAgentOlderTimelinePages(
page: Page, page: Page,
agentId: string, agentId: string,
daemonPort?: number, options: OlderTimelinePagesOptions = {},
): Promise<OlderTimelinePagesGate> { ): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0; let requestCount = 0;
let responseCount = 0; let responseCount = 0;
let repeatedEntryCount = 0; let repeatedEntryCount = 0;
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
} }
server.send(message); server.send(message);
}); });
server.onMessage((message) => { server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
const sessionMessage = getSessionMessage(message); const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null; const payload = sessionMessage ? getPayload(sessionMessage) : null;
if ( if (
@@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter"); 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> { export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({ await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000, timeout: 10_000,
@@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
.getByRole("button", { name: "Add", exact: true }) .getByRole("button", { name: "Add", exact: true })
.click(); .click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1); await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
// Config updates before provider refresh finishes and dismisses the add sheet.
// Resizing or interacting with the model list must wait for that save to finish.
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
} }
export async function removeCustomModel(page: Page, id: string): Promise<void> { export async function removeCustomModel(page: Page, id: string): Promise<void> {
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page); await expectMobileAgentSidebarVisible(this.page);
} }
await openGlobalNewWorkspaceComposer(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).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport(); await expect(button).toBeInViewport();
} }
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace(); await this.openFromNewWorkspace();
} }
async 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(); await this.expectSheetReady();
} }
async openFromWorkspaceHeader() { async openFromWorkspaceHeader() {
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width); expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
} }
async revealSession(id: string) { 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) { async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query); await this.page.getByTestId("import-session-search").fill(query);
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
} }
async loadMore() { async loadMore() {
await this.page.getByTestId("import-session-load-more").click(); 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); await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
} }
async retryProvider(provider: string, label: string) { async retryProvider(provider: string, label: string) {
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0); await expect(this.page.getByRole("progressbar")).toHaveCount(0);
} }
async importSession(id: string) { async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click(); await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, { await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
); );
} }
async close() { 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); await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
} }
async expectCommandCenterMatch() { async expectCommandCenterMatch() {
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e"; const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT = export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming."; "Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -116,7 +172,7 @@ export async function withTimelinePlugin(
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
"@paseo:app-settings", "@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }), JSON.stringify({ chatDisplay: "collapseToolCalls" }),
); );
}); });
const agent = await seedMockAgentWorkspace({ const agent = await seedMockAgentWorkspace({
@@ -149,7 +149,11 @@ export interface SeedDaemonClient {
timeout?: number, timeout?: number,
): Promise<{ status: string }>; ): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>; cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>; sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish( waitForFinish(
agentId: string, agentId: string,
timeout?: number, timeout?: number,
@@ -42,6 +42,7 @@ type HostSection =
| "pair-device" | "pair-device"
| "agents" | "agents"
| "metadata" | "metadata"
| "voice"
| "workspaces" | "workspaces"
| "providers" | "providers"
| "usage" | "usage"
@@ -57,6 +58,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section)); 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> { export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by // 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. // 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(); 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( export async function openHostSection(
page: Page, page: Page,
serverId: string, serverId: string,
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
): Promise<void> { ): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ key, sidebarFooterItems }) => { ({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return; const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems })); if (settings.sidebarFooterItems) return;
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
}, },
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences }, { key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
); );
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
} }
export async function archiveFinishedSubagents(page: Page): Promise<void> { 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( export async function expectArchiveFinishedInProgress(
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
completed: number, completed: number,
total: number, total: number,
): Promise<void> { ): 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(); await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
} }
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
total: number, total: number,
): Promise<void> { ): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible(); 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( export async function holdManagedSubagentArchiveRequest(
@@ -1,4 +1,4 @@
import { expect, type TestInfo, type Page } from "@playwright/test"; import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage"; import { readReplicaCache } from "./replica-cache-storage";
import { import {
@@ -9,6 +9,7 @@ import {
type AgentTimelineResponseGate, type AgentTimelineResponseGate,
type BootstrapTimelineGate, type BootstrapTimelineGate,
type OlderTimelinePagesGate, type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate"; } from "./agent-timeline-gate";
export { holdDaemonHydration }; export { holdDaemonHydration };
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string; newestPrompt: string;
} }
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
newestPrompt: string;
/** Commands in one run, more than an item page holds. */
totalCommands: number;
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
loadedCommands: number;
}
interface ToolCallGroupPositionSnapshot {
top: number;
}
const ITEM_PAGE_SIZE = 40;
const PROMPT_PREFIX = "timeline-pagination-turn"; const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration"; const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96; const HISTORY_START_THRESHOLD_PX = 96;
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
}; };
} }
/**
* A run of commands longer than an item page, followed by enough short turns that the
* initial tail ends inside the run. Read with item pages, the tail loads the newest
* commands as one group; the older page carries the rest of that run and the turns before
* it, with a further page behind it.
*/
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-item-page-tool-group-",
title: "Item page tool group regression",
});
const olderTurns = 10;
const newerTurns = 12;
const totalCommands = 50;
// A short turn is its prompt and one reply; the run's turn ends with one reply.
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
const shortTurnPrompt = (label: string, index: number) =>
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
const sendTurn = async (prompt: string) => {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
};
for (let index = 0; index < olderTurns; index += 1) {
await sendTurn(shortTurnPrompt("older", index));
}
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
for (let index = 0; index < newerTurns; index += 1) {
await sendTurn(shortTurnPrompt("newer", index));
}
return {
...agent,
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
totalCommands,
loadedCommands,
};
}
export async function useChatDisplay(
page: Page,
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
function toolCallGroup(page: Page, commandCount: number): Locator {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return timeline
.getByTestId("tool-call-group")
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
}
export async function rememberToolCallGroupPosition(
page: Page,
commandCount: number,
): Promise<ToolCallGroupPositionSnapshot> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
const box = await group.boundingBox();
if (!box) {
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
}
return { top: box.y };
}
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
export async function expectToolCallGroupPositionPreserved(
page: Page,
commandCount: number,
before: ToolCallGroupPositionSnapshot,
): Promise<void> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
await expect
.poll(
async () => {
const box = await group.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
},
{ message: `the group of ${commandCount} commands keeps its viewport position` },
)
.toBeLessThanOrEqual(2);
}
export async function scrollThroughOlderHistoryPages( export async function scrollThroughOlderHistoryPages(
page: Page, page: Page,
pageCount: number, pageCount: number,
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages( export async function holdOlderHistoryPages(
page: Page, page: Page,
agent: MockAgentWorkspace, agent: MockAgentWorkspace,
daemonPort?: number, options?: OlderTimelinePagesOptions,
): Promise<OlderHistoryPages> { ): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages( const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page, page,
agent.agentId, agent.agentId,
daemonPort, options,
); );
const loader = page.getByTestId("load-older-history-spinner");
return { return {
async expectRequestedPages(count) { async expectRequestedPages(count) {
await gate.waitForRequestCount(count); await gate.waitForRequestCount(count);
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
}, },
async expectSettledWithRequestedPages(count) { async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page); await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count); // The loader stays until the prepend settles and the viewport decides whether to
// keep loading. Once it hides, no further page loads without new user intent.
await expect
.poll(
async () => ({
requestedPages: gate.getRequestCount(),
loading: await loader.isVisible(),
}),
{ message: `older history settles after ${count} requested page(s)` },
)
.toEqual({ requestedPages: count, loading: false });
}, },
expectNoRepeatedEntries() { expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0); expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
} }
/**
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
* mid-turn: its prompt is above loaded history. Returns that prompt.
*/
export async function expectOldestLoadedTurnPartial(
page: Page,
prompts: readonly string[],
): Promise<string> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const oldestLoadedPrompt = await timeline
.getByTestId("user-message")
.first()
.evaluate((element) => element.textContent ?? "");
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
const oldestRow = timeline.locator("[data-history-row-id]").first();
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
0,
);
const partialPrompt = prompts[loadedIndex - 1]!;
await expectTimelinePromptNotMounted(page, partialPrompt);
return partialPrompt;
}
/** The reader stays with the turn they were reading once it folds behind its summary. */
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const offset = promptBox.y - timelineBox.y;
return offset >= 0 && offset <= timelineBox.height / 2;
},
{ message: "folded turn sits in the upper half of the viewport" },
)
.toBe(true);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> { export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop) .poll(async () => (await readTimelineViewport(page)).scrollTop)
@@ -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);
}
}
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { z } from "zod"; import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
type WorkspaceTab, type WorkspaceTab,
} from "../../../src/workspace-tabs/model"; } from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */ /** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> { export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.2", "version": "0.14.0-beta.1",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk", "build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64", "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": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
@@ -76,8 +76,8 @@
"@xterm/addon-search": "^0.17.0-beta.213", "@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "^0.20.0-beta.212", "@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "^6.1.0-beta.213", "@xterm/xterm": "6.1.0-beta.292",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"expo": "~57.0.30", "expo": "~57.0.30",
"expo-asset": "~57.0.20", "expo-asset": "~57.0.20",
@@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = { const handle: StreamViewportHandle = {
scrollToBottom() {}, scrollToBottom() {},
prepareForViewportChange() {}, prepareForViewportChange() {},
holdReadingPosition() {},
scrollToMessage(_messageId, occurrence) { scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence; if (occurrence) capture.occurrence = occurrence;
}, },
@@ -265,6 +265,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = { const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(), scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(), prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage, scrollToMessage,
}; };
const viewportRef = { current: viewport }; const viewportRef = { current: viewport };
@@ -316,6 +317,7 @@ describe("useChatOutline", () => {
current: { current: {
scrollToBottom: vi.fn(), scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(), prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage, scrollToMessage,
}, },
}; };
@@ -371,6 +373,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = { const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(), scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(), prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage, scrollToMessage,
}; };
const livePrompt = { const livePrompt = {
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive, isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]), history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [], liveHead: [],
timingByAssistantId: timingFor(assistant.id), timingByItemId: timingFor(assistant.id),
}), }),
}; };
} }
@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { continuesTurn } from "./turn-membership";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20; export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
@@ -32,7 +33,7 @@ export function findMountedWindowStart(input: {
} }
let startIndex = Math.max(items.length - minMountedCount, 0); let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") { while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1; startIndex -= 1;
} }
return startIndex; return startIndex;
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy, strategy,
streamHead: input.head ?? [], streamHead: input.head ?? [],
}), }),
timingByAssistantId: timingFor(...(input.timingIds ?? [])), timingByItemId: timingFor(...(input.timingIds ?? [])),
}); });
} }
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
} }
describe("layoutStream", () => { describe("layoutStream", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => { it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2"); const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({ const layout = layoutFor({
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id], timingIds: [priorAssistant.id],
}); });
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id); expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]); expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
}); });
it("recomputes cached history layout when only the live boundary turn changes", () => { it("recomputes cached history layout when only the live boundary turn changes", () => {
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
isTurnActive: true, isTurnActive: true,
history, history,
liveHead, liveHead,
timingByAssistantId: timingFor(priorAssistant.id), timingByItemId: timingFor(priorAssistant.id),
}); });
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
isTurnActive: true, isTurnActive: true,
history, history,
liveHead, liveHead,
timingByAssistantId: timingFor(priorAssistant.id), timingByItemId: timingFor(priorAssistant.id),
}); });
expect(footerOwners(first)).toEqual([]); expect(footerOwners(first)).toEqual([]);
+29 -26
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean; isTurnActive: boolean;
history: StreamItem[]; history: StreamItem[];
liveHead: StreamItem[]; liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>; timingByItemId: Map<string, TurnTiming>;
} }
interface LayoutSegmentInput { interface LayoutSegmentInput {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>; timingByItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean; hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder; frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete"; phase: "streaming" | "complete";
} }
interface AssistantFooterSource { interface TurnFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>; item: StreamItem;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
} }
@@ -67,23 +67,26 @@ function createTurnFooterHost(input: {
item: StreamItem; item: StreamItem;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
timingByAssistantId: Map<string, TurnTiming>; timingByItemId: Map<string, TurnTiming>;
}): TurnFooterHost { }): TurnFooterHost {
return { return {
itemId: input.item.id, itemId: input.item.id,
items: input.items, items: input.items,
timing: input.timingByAssistantId.get(input.item.id), timing: input.timingByItemId.get(input.item.id),
startIndex: input.index, startIndex: input.index,
}; };
} }
function findLatestAssistantInResponse(input: { function findTurnFooterSource(input: {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
startIndex: number; startIndex: number;
boundaryAboveItems?: StreamItem[] | null; boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null; boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null { }): TurnFooterSource | null {
const last = input.items[input.startIndex];
const toolFallback: TurnFooterSource | null =
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
let items = input.items; let items = input.items;
let index = input.startIndex; let index = input.startIndex;
let canCrossBoundary = true; let canCrossBoundary = true;
@@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: {
) { ) {
const item = items[index]; const item = items[index];
if (!item || (laterItem && !continuesResponse(item, laterItem))) { if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null; return toolFallback;
} }
if (item.kind === "assistant_message") { if (item.kind === "assistant_message") {
return { item, items, index }; return { item, items, index };
@@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: {
input.boundaryAboveIndex === null || input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined input.boundaryAboveIndex === undefined
) { ) {
return null; return toolFallback;
} }
items = input.boundaryAboveItems; items = input.boundaryAboveItems;
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null; return null;
} }
const assistant = findLatestAssistantInResponse({ const source = findTurnFooterSource({
strategy: input.strategy, strategy: input.strategy,
items: footerItems, items: footerItems,
startIndex: latestIndex, startIndex: latestIndex,
}); });
if (!assistant) { if (!source) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: assistant.item, item: source.item,
items: assistant.items, items: source.items,
index: assistant.index, index: source.index,
timingByAssistantId: input.timingByAssistantId, timingByItemId: input.timingByItemId,
}); });
} }
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
index: number; index: number;
item: StreamItem; item: StreamItem;
belowItem: StreamItem | null; belowItem: StreamItem | null;
timingByAssistantId: Map<string, TurnTiming>; timingByItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null; boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null; boundaryAboveIndex: number | null;
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
return null; return null;
} }
const assistant = findLatestAssistantInResponse({ const source = findTurnFooterSource({
strategy: input.strategy, strategy: input.strategy,
items: input.items, items: input.items,
startIndex: input.index, startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
}); });
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: assistant.item, item: source.item,
items: assistant.items, items: source.items,
index: assistant.index, index: source.index,
timingByAssistantId: input.timingByAssistantId, timingByItemId: input.timingByItemId,
}); });
} }
@@ -306,7 +309,7 @@ function layoutSegmentItem(
index, index,
item, item,
belowItem, belowItem,
timingByAssistantId: input.timingByAssistantId, timingByItemId: input.timingByItemId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter, auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
@@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({ history = layoutSegment({
strategy: input.strategy, strategy: input.strategy,
items: input.history, items: input.history,
timingByAssistantId: input.timingByAssistantId, timingByItemId: input.timingByItemId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
@@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({ const liveHead = layoutSegment({
strategy: input.strategy, strategy: input.strategy,
items: input.liveHead, items: input.liveHead,
timingByAssistantId: input.timingByAssistantId, timingByItemId: input.timingByItemId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
+9 -9
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2, historyStart: 2,
}); });
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
completedAt: tail[3]?.timestamp, completedAt: tail[3]?.timestamp,
durationMs: 1000, durationMs: 1000,
}); });
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
}); });
it("maps completed turn timing to assistant ids across committed history and live head", () => { it("maps completed turn timing to assistant ids across committed history and live head", () => {
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.turnTiming.runningStartedAt).toBe(null); expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ expect(model.turnTiming.byItemId.get("live-a")).toEqual({
completedAt: head[0]?.timestamp, completedAt: head[0]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ expect(model.turnTiming.byItemId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp, completedAt: tail[1]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
expect(model.turnTiming.byAssistantId.size).toBe(0); expect(model.turnTiming.byItemId.size).toBe(0);
}); });
it.each(["web", "native"] as const)( it.each(["web", "native"] as const)(
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
expect(second.history).toBe(first.history); expect(second.history).toBe(first.history);
expect(second.segments.historyMounted).toBe(first.segments.historyMounted); expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId); expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
expect(first.history).toHaveLength(20); expect(first.history).toHaveLength(20);
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false); expect(first.turnTiming.byItemId.has("a29")).toBe(false);
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({ expect(first.turnTiming.byItemId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp, completedAt: tail[77]?.timestamp,
durationMs: 1000, durationMs: 1000,
}); });
@@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
import type { InstalledPlugin } from "@/plugins/types"; import type { InstalledPlugin } from "@/plugins/types";
import { import {
applyStreamEvent, applyStreamEvent,
getStreamItemMessageId,
hydrateStreamState, hydrateStreamState,
type StreamItem, type StreamItem,
type ToolCallItem, type ToolCallItem,
@@ -13,7 +14,8 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model"; import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; import { createStreamPresentation } from "./presentation";
import { createReadingAnchor } from "./reading-anchor";
const runtime = { const runtime = {
paseo: {}, paseo: {},
@@ -97,7 +99,11 @@ function pluginData(items: StreamItem[]): unknown[] {
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : [])); 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) { function streamHarness(transform?: TimelineItemTransform) {
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
@@ -583,3 +589,197 @@ describe("timeline presentation", () => {
expect(projectTimelineItems([source], () => [])).toEqual([]); expect(projectTimelineItems([source], () => [])).toEqual([]);
}); });
}); });
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
toolCall("two", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const result = present({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
it.each(["collapseToolCalls", "collapseTurns"] as const)(
"preserves the reader when a prepend extends a group in %s",
(display) => {
const present = createStreamPresentation();
const anchor = createReadingAnchor();
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
const input = {
...presentationOptions,
transform: undefined,
display,
head: [],
isTurnActive: false,
};
const partial = present({ ...input, tail: calls.slice(1) });
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
const completed = present({ ...input, tail: calls });
const groupId = completed.tail[0]!.id;
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
expect(
anchor.reconcile(
0,
[{ id: groupId, top: 448, height: 200 }],
false,
completed.readingRowReplacements,
),
).toBe(400);
expect(anchor.getRowId()).toBe(groupId);
const timestamp = new Date();
const folded = present({
...input,
display: "collapseTurns",
tail: [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
...calls,
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
},
);
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
call.turnId = "turn";
call.payload.data.agentMessage = {
event: "message",
sender: { id: "sender", title: "Reviewer" },
text: "Ready",
};
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail: [call],
head: [],
});
expect(result.tail[0]).toMatchObject({
kind: "user_message",
id: call.id,
turnId: "turn",
text: "Ready",
sender: { id: "sender", title: "Reviewer" },
});
});
it("does not reopen the previous turn while a new run waits for its first row", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
// the completed one; it must stay folded until the new turn's rows arrive.
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const thought: StreamItem = {
kind: "thought",
id: "thought",
text: "Consider this",
status: "ready",
timestamp,
};
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
for (const item of tail) item.turnId = "run";
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseToolCalls" as const,
isTurnActive: false,
};
const grouped = present({ ...input, autoExpandReasoning: false });
expect(grouped.tail).toHaveLength(1);
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
const separate = present({ ...input, autoExpandReasoning: true });
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
expect(separate.groupsByHostId.has("thought")).toBe(false);
const next = { ...thought, id: "next", turnId: "next-run" };
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
});
it.each([false, true])(
"reveals raw reasoning when opening a turn (Expand thinking %s)",
(autoExpandReasoning) => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns" as const,
isTurnActive: false,
autoExpandReasoning,
};
const present = createStreamPresentation();
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
);
+147 -11
View File
@@ -1,3 +1,5 @@
import { isTurnBoundary } from "./turn-membership";
import type { ChatDisplay } from "@/hooks/use-settings";
import type { TimelineItemTransform } from "@/plugins/timeline/model"; import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { import {
@@ -5,23 +7,51 @@ import {
projectToolCallDetailLevel, projectToolCallDetailLevel,
type PreparedToolCallHistory, type PreparedToolCallHistory,
type ToolCallDetailLevel, type ToolCallDetailLevel,
type ToolCallDetailProjection,
} from "@/tool-calls/detail-level/projection"; } from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream"; import type { StreamItem, UserMessageItem } from "@/types/stream";
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
import type { ReadingRowReplacements } from "./reading-anchor";
interface PresentationInput { interface PresentationInput {
tail: StreamItem[]; tail: StreamItem[];
head: StreamItem[]; head: StreamItem[];
transform: TimelineItemTransform | undefined; transform: TimelineItemTransform | undefined;
level: ToolCallDetailLevel; display: ChatDisplay;
autoExpandReasoning?: boolean;
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean; isTurnActive: boolean;
activeTurnId?: string | null;
} }
/** Find and scrolling address a plugin row by the source it replaces. */ export interface StreamPresentation extends ToolCallDetailProjection {
export function getStreamItemMessageId(item: StreamItem): string { turns: Omit<TurnCollapse, "tail">;
if (item.kind === "plugin") return item.sourceId ?? item.id; readingRowReplacements: ReadingRowReplacements;
return item.id;
} }
function groupedRowHosts(
rows: readonly StreamItem[],
groups: ToolCallDetailProjection["groupsByHostId"],
): ReadonlyMap<string, string> {
const hosts = new Map<string, string>();
for (const row of rows) {
const group = groups.get(row.id);
if (!group) continue;
for (const call of group.run.calls) {
if (call.id !== row.id) hosts.set(call.id, row.id);
}
}
return hosts;
}
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
tail: [],
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
});
/** Source messages reach plugins before display projection. */ /** Source messages reach plugins before display projection. */
export function createStreamPresentation() { export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>(); const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -45,14 +75,89 @@ export function createStreamPresentation() {
let historyTransform: TimelineItemTransform | undefined; let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = []; let historyRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined; let preparedTail: StreamItem[] | undefined;
let preparedAutoExpandReasoning: boolean | undefined;
let preparedLevel: ToolCallDetailLevel | undefined; let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null; let preparedHistory: PreparedToolCallHistory | null = null;
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
let collapsedSource: StreamItem[] | undefined;
let collapsedIsLatestTurnOpen = false;
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] { function presentRows(item: StreamItem): StreamItem[] {
if (item.kind === "tool_call" && item.payload.source === "agent") {
const message = item.payload.data.agentMessage;
if (message?.event === "message") {
return [
{
kind: "user_message",
id: item.id,
turnId: item.turnId,
timelineCursor: item.timelineCursor,
timestamp: item.timestamp,
text: message.text,
sender: message.sender,
},
];
}
}
return [item.kind === "user_message" ? presentUserMessage(item) : item]; return [item.kind === "user_message" ? presentUserMessage(item) : item];
} }
return (input: PresentationInput) => { function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): Omit<StreamPresentation, "readingRowReplacements"> {
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 last = projection.tail.at(-1) ?? null;
const first = projection.head[0] ?? null;
const activeTurnIsAnother = Boolean(
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
);
// Without head rows, the latest retained turn is live only while the active run is that
// turn. A run that has not opened yet carries no id and belongs to the next turn.
const isLatestTurnOpen = first
? !isTurnBoundary(last, first) && !activeTurnIsAnother
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
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;
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
});
return {
...projection,
tail,
turns,
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
};
}
return (input: PresentationInput): StreamPresentation => {
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
// Retained history is not reprojected or regrouped on each live text update. // Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) { if (historySource !== input.tail || historyTransform !== input.transform) {
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>( historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
@@ -66,17 +171,48 @@ export function createStreamPresentation() {
const blocks = presentRows(item); const blocks = presentRows(item);
head.push(...blocks); head.push(...blocks);
} }
if (preparedTail !== historyRows || preparedLevel !== input.level) { if (
preparedHistory = prepareToolCallHistory(input.level, historyRows); preparedTail !== historyRows ||
preparedLevel !== level ||
preparedAutoExpandReasoning !== input.autoExpandReasoning
) {
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
historyGroupHosts = preparedHistory
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
: new Map();
preparedTail = historyRows; preparedTail = historyRows;
preparedLevel = input.level; preparedLevel = level;
preparedAutoExpandReasoning = input.autoExpandReasoning;
} }
return projectToolCallDetailLevel({ const projection = projectToolCallDetailLevel({
level: input.level, level,
tail: historyRows, tail: historyRows,
head, head,
preparedHistory, preparedHistory,
isTurnActive: input.isTurnActive, isTurnActive: input.isTurnActive,
}); });
const result = collapse(projection, input);
// Only the trailing retained group can absorb head rows. Reuse the retained
// index across streaming updates rather than walking the full history again.
const last = projection.tail.at(-1);
const liveGroupHosts = groupedRowHosts(
last ? [last, ...projection.head] : projection.head,
projection.groupsByHostId,
);
const retainedGroupHosts = historyGroupHosts;
return {
...result,
readingRowReplacements: {
get(id) {
if (result.turns.nestedItemIds.has(id)) return undefined;
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
return (
result.turns.hostIdByHiddenRowId.get(id) ??
result.turns.hostIdByHiddenRowId.get(groupHost) ??
(groupHost === id ? undefined : groupHost)
);
},
},
};
}; };
} }
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { createReadingAnchor } from "./reading-anchor"; import { createReadingAnchor } from "./reading-anchor";
describe("reading anchor", () => { describe("reading anchor", () => {
it("pins the folded turn host when a prepend replaces the row being read", () => {
const anchor = createReadingAnchor();
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
const replacements = new Map([["tool", "prompt"]]);
expect(anchor.getRowId(replacements)).toBe("prompt");
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
// Projection does not consume the correction; an unmounted host keeps the pin.
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
expect(anchor.getRowId()).toBe("tool");
expect(
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
expect(anchor.getRowId()).toBe("prompt");
expect(
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
});
const rows = [ const rows = [
{ id: "above", top: 0, height: 500 }, { id: "above", top: 0, height: 500 },
{ id: "reading", top: 500, height: 500 }, { id: "reading", top: 500, height: 500 },
@@ -6,6 +6,10 @@ interface RowGeometry {
// A row must clear the reading line before the next row takes ownership. // A row must clear the reading line before the next row takes ownership.
const READING_POSITION_OFFSET_PX = 8; const READING_POSITION_OFFSET_PX = 8;
export interface ReadingRowReplacements {
get(id: string): string | undefined;
}
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
// Content coordinates survive user scrolling. Layout commits replace the geometry; // Content coordinates survive user scrolling. Layout commits replace the geometry;
// scroll events reuse it, without another DOM measurement pass. // scroll events reuse it, without another DOM measurement pass.
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
let geometry: readonly RowGeometry[] = []; let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) => const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) => const getRowId = (replacements = NO_REPLACEMENTS) =>
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
const project = (
scrollTop: number,
row: Pick<RowGeometry, "id" | "top"> | undefined,
replacements = NO_REPLACEMENTS,
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
return { return {
getRowId: () => anchor?.id ?? null, getRowId,
getReadingRowId: (scrollTop: number) => getReadingRowId: (scrollTop: number) =>
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
project, project,
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
const next = readingRow(scrollTop); const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null; anchor = next ? { id: next.id, top: next.top } : null;
}, },
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { reconcile(
scrollTop: number,
rows: readonly RowGeometry[],
userScrolled = false,
replacements = NO_REPLACEMENTS,
): number {
geometry = rows; geometry = rows;
const previous = anchor && rows.find((row) => row.id === anchor?.id); const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
// The first virtualized commit can precede its mounted range. Keep the // The first virtualized commit can precede its mounted range. Keep the
// pinned reader until it mounts, rather than adopting an unrelated row. // pinned reader until it mounts, rather than adopting an unrelated row.
if (anchor && !previous && !userScrolled) return scrollTop; if (anchor && !previous && !userScrolled) return scrollTop;
const correctedTop = project(scrollTop, previous ?? undefined); const correctedTop = project(scrollTop, previous ?? undefined, replacements);
// A prepend can expose the bottom of an estimated row above the reader. // A prepend can expose the bottom of an estimated row above the reader.
// Do not transfer ownership to it until the user moves the reading position. // Do not transfer ownership to it until the user moves the reading position.
const next = previous && !userScrolled ? previous : readingRow(correctedTop); const next = previous && !userScrolled ? previous : readingRow(correctedTop);
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2"); ).toBe("assistant-1\n\nassistant-2");
}); });
it("collects copy content across adjacent turns without a visible prompt", () => { it("keeps copied content within the canonical turn without a visible prompt", () => {
const chronological: StreamItem[] = [ const chronological: StreamItem[] = [
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
{ ...toolCall("tool", 2), turnId: "turn-2" }, { ...toolCall("tool", 2), turnId: "turn-2" },
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
items: chronological, items: chronological,
startIndex: 2, startIndex: 2,
}), }),
).toBe("first turn\n\nsecond turn"); ).toBe("second turn");
}); });
it("returns undefined neighbor when index would be out of bounds", () => { it("returns undefined neighbor when index would be out of bounds", () => {
@@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange(); bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling(); markNativeViewportSettling();
}, },
holdReadingPosition: () => bottomAnchorController.detachByUser(),
}; };
viewportRef.current = handle; viewportRef.current = handle;
return () => { return () => {
@@ -7,7 +7,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel"; import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StreamItem } from "@/types/stream"; import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy"; import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web"; import { createWebStreamStrategy } from "./strategy-web";
@@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it.each(["liveHead", "historyMounted"] as const)(
"keeps one rendered tool row after late prior-turn updates in %s",
(lane) => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: lane === "historyMounted",
hasLiveHead: lane === "liveHead",
},
renderers: createRenderers(() => {}),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
let items: StreamItem[] = [];
const updates = [
{ callId: "background", turnId: "old", status: "running" },
{ callId: "exec", turnId: "current", status: "running" },
{ callId: "background", turnId: "old", status: "completed" },
{ callId: "exec", turnId: "current", status: "completed" },
] as const;
for (const [index, update] of updates.entries()) {
items = reduceStreamUpdate(
items,
{
type: "timeline",
provider: "codex",
turnId: update.turnId,
item: {
type: "tool_call",
callId: update.callId,
name: "exec_command",
status: update.status,
error: null,
detail: { type: "shell", command: update.callId },
},
},
new Date(index),
);
act(() =>
root?.render(
strategy.render({
...input,
segments: { ...input.segments, [lane]: items },
}),
),
);
}
const rows = [...container.querySelectorAll("[data-history-row-id]")];
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
"agent_tool_turn:old/background",
"agent_tool_turn:current/exec",
]);
},
);
it("mounts virtualized history without recursive row measurement updates", () => { it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn(); const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
+23 -9
View File
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event"; 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 type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -44,7 +44,6 @@ import {
type HistoryStartSettleScheduler, type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler"; } from "./history-start-settle-scheduler";
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find"; import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
import { getStreamItemMessageId } from "./presentation";
import { useScrollToMessage } from "./use-scroll-to-message.web"; import { useScrollToMessage } from "./use-scroll-to-message.web";
interface CreateWebStreamStrategyInput { interface CreateWebStreamStrategyInput {
@@ -395,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rangeExtractor = useCallback( const rangeExtractor = useCallback(
(range: VirtualRange): number[] => { (range: VirtualRange): number[] => {
const anchorIndex = segments.historyVirtualized.findIndex( const anchorIndex = segments.historyVirtualized.findIndex(
(row) => row.id === readingAnchor.getRowId(), (row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
); );
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
// TanStack has computed the new measurements before calling the extractor. // TanStack has computed the new measurements before calling the extractor.
@@ -405,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const measurement = virtualizer.measurementsCache[anchorIndex]; const measurement = virtualizer.measurementsCache[anchorIndex];
const correctedOffset = const correctedOffset =
container && measurement container && measurement
? readingAnchor.project(container.scrollTop, { ? readingAnchor.project(
id: String(measurement.key), container.scrollTop,
top: measurement.start, {
}) id: String(measurement.key),
top: measurement.start,
},
props.readingRowReplacements,
)
: container?.scrollTop; : container?.scrollTop;
let visibleRange = range; let visibleRange = range;
if ( if (
@@ -429,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]), ]),
].sort((left, right) => left - right); ].sort((left, right) => left - right);
}, },
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized], [chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
); );
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => { const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
@@ -743,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
); );
const shouldAnchor = !followOutputRef.current && !isJumpSettling(); const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset(); if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); const correctedTop = readingAnchor.reconcile(
container.scrollTop,
rows,
false,
props.readingRowReplacements,
);
if (!shouldAnchor) { if (!shouldAnchor) {
readingAnchor.reset(); readingAnchor.reset();
return; return;
@@ -1174,6 +1182,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} }
scheduleStickToBottom(); scheduleStickToBottom();
}, },
holdReadingPosition: () => {
cancelPendingStickToBottom();
setFollowOutput(false);
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
},
scrollToMessage, scrollToMessage,
}; };
viewportRef.current = handle; viewportRef.current = handle;
@@ -1186,6 +1199,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [ }, [
cancelPendingStickToBottom, cancelPendingStickToBottom,
forceStickToBottom, forceStickToBottom,
readingAnchor,
scheduleStickToBottom, scheduleStickToBottom,
scrollToMessage, scrollToMessage,
viewportRef, viewportRef,
@@ -1,6 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { ReadingRowReplacements } from "./reading-anchor";
import { continuesResponse } from "./turn-membership"; import { continuesResponse } from "./turn-membership";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type { import type {
@@ -55,6 +56,8 @@ export interface StreamViewportHandle {
focus?: () => void; focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => 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; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
} }
@@ -76,6 +79,8 @@ export interface StreamRenderInput {
segments: StreamRenderSegments; segments: StreamRenderSegments;
historyRowRevision?: StreamHistoryRowRevision; historyRowRevision?: StreamHistoryRowRevision;
liveHeadRowRevision?: unknown; liveHeadRowRevision?: unknown;
/** Presentation rows replaced by another row after history is folded. */
readingRowReplacements?: ReadingRowReplacements;
boundary: StreamHistoryBoundary; boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers; renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode; listEmptyComponent: ReactNode;
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
isTurnActive: true, isTurnActive: true,
history: model.history, history: model.history,
liveHead: model.segments.liveHead, liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId, timingByItemId: model.turnTiming.byItemId,
}); });
} }
} }
@@ -0,0 +1,204 @@
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("maps hidden rows only while their turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
expandedHostIds: new Set(["u1"]),
});
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
});
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
const create = shell("create");
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
create.payload.data.name = "mcp__paseo__create_agent";
create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null };
const finished = shell("finished");
if (finished.payload.source !== "agent") throw new Error("Expected agent tool");
finished.payload.data.name = "agent_notification";
finished.payload.data.agentMessage = {
event: "finished",
sender: { id: "worker", title: "PR helper" },
text: "Finished",
};
const items: StreamItem[] = [
user("u1"),
create,
finished,
{
kind: "compaction",
id: "compaction",
timestamp: at,
status: "completed",
} satisfies StreamItem,
shell("bash"),
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
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: {
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("keeps a completed response folded when a promptless canonical turn starts", () => {
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
Object.assign(item, { turnId: "first" }),
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
it("reveals every raw call inside an expanded turn", () => {
const calls = [shell("one"), shell("two")];
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
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,159 @@
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
import {
getStreamItemMessageId,
type StreamItem,
type ThoughtItem,
type ToolCallItem,
} from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The input or first loaded row that 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>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: 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 | ThoughtItem)[] | 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 | ThoughtItem)[] = [];
for (const item of items) {
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
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. Reasoning is revealed only when the turn opens.
*/
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>();
const hostIdByHiddenRowId = 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 ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
!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) && !folded.some((item) => item.kind === "assistant_message")) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
? [...(input.getGroupedCalls(item.id) ?? [item])]
: [item],
)
: folded;
for (const item of revealed) {
if (staysVisibleWhenCollapsed(item)) continue;
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
else hostIdByHiddenRowId.set(item.id, host.id);
}
output.push(
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (
!(index === 0 && isTurnTrigger(tail[index]!)) &&
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
)
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,
hostIdByHiddenRowId: EMPTY_HOSTS,
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
};
}
@@ -0,0 +1,36 @@
import { memo, useCallback } 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 label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
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
/>
);
});
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
return ( return (
<View style={stylesheet.turnFooterSlot}> <View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter <AssistantTurnFooter
getContent={getContent} getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
completedAt={timing?.completedAt} completedAt={timing?.completedAt}
durationMs={timing?.durationMs} durationMs={timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined} onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
isTurnActive, isTurnActive,
history: model.segments.historyMounted, history: model.segments.historyMounted,
liveHead: model.segments.liveHead, liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId, timingByItemId: model.turnTiming.byItemId,
}), }),
}; };
} }
@@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
} }
describe("canonical turn membership", () => { describe("canonical turn membership", () => {
it("uses trigger fallback for missing IDs without pretending to recover batching", () => {
const previous = assistant("reply", 1, "first");
const trigger = runningTool("heartbeat", 2, "first");
if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent")
throw new Error("Expected agent tool");
trigger.payload.data.name = "heartbeat";
expect(continuesTurn(previous, trigger)).toBe(true);
delete trigger.turnId;
expect(continuesTurn(previous, trigger)).toBe(false);
trigger.payload.data.name = "agent_message";
trigger.payload.data.agentMessage = {
event: "finished",
sender: { id: "worker" },
text: "Done",
};
expect(continuesTurn(previous, trigger)).toBe(false);
});
it("stops tagged traversal at every mismatching turn ID", () => { it("stops tagged traversal at every mismatching turn ID", () => {
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe( expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
false, false,
@@ -76,7 +94,7 @@ describe("canonical turn membership", () => {
); );
}); });
it("renders adjacent turns without visible prompts as one response", () => { it("renders a separate footer for each canonical turn without visible prompts", () => {
const items = [ const items = [
user("prompt", 1, "turn-1"), user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"), assistant("first reply", 2, "turn-1"),
@@ -88,7 +106,11 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false); const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]); expect(completedFooterIds(completed.layout)).toEqual([
"first reply",
"heartbeat-1-reply",
"heartbeat-2-reply",
]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({ expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6), completedAt: at(6),
durationMs: null, durationMs: null,
@@ -123,7 +145,7 @@ describe("canonical turn membership", () => {
const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completedItems = [...build(turnId), assistant("done", 9, turnId)];
const completed = layoutFor(completedItems, false); const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]); expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
completedAt: at(9), completedAt: at(9),
durationMs: 8000, durationMs: 8000,
}); });
@@ -1,24 +1,21 @@
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
/** /**
* Canonical turn IDs take precedence. Timelines without them retain the legacy * Canonical turn IDs take precedence. Timelines without them retain the legacy
* user-message boundary rule so old daemons and persisted rows need no rewrite. * trigger boundary rule; missing IDs cannot reconstruct historical batching.
*/ */
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false; if (!previous || !next) return false;
if (previous.turnId !== undefined && next.turnId !== undefined) { return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
return previous.turnId === next.turnId;
}
return next.kind !== "user_message";
} }
/** /**
* A visible response can span multiple canonical turns when their prompts are * Every canonical turn owns its response, including system-triggered runs.
* system-injected and therefore absent from the Paseo timeline.
*/ */
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false; if (!previous || !next) return false;
return continuesTurn(previous, next) || next.kind !== "user_message"; return continuesTurn(previous, next);
} }
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
return turnId !== null && item.turnId === turnId; return turnId !== null && item.turnId === turnId;
} }
/** Inputs that start a turn when older history has no canonical turn IDs. */
export function isTurnTrigger(item: StreamItem): boolean {
return isTimelineTurnTrigger(
item.kind === "tool_call" && item.payload.source === "agent"
? { type: item.kind, ...item.payload.data }
: { type: item.kind },
);
}
@@ -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 type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual"; import type { Virtualizer } from "@tanstack/react-virtual";
import type { StreamItem } from "@/types/stream"; import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import { getStreamItemMessageId } from "./presentation";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
import type { ScrollToMessageOccurrence } from "./strategy"; import type { ScrollToMessageOccurrence } from "./strategy";
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getStreamItemMessageId } from "./presentation";
// Callers reveal a message, not a row: an assistant message is several block rows and // 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. // the window has to open at the first of them.
+175 -28
View File
@@ -41,7 +41,7 @@ import {
type InlinePathTarget, type InlinePathTarget,
} from "@/components/message"; } from "@/components/message";
import { PlanCard } from "@/components/plan-card"; 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 { PendingMessageSubmission } from "@/composer/submission/model";
import type { TurnPresentation } from "@/timeline/turn-liveness"; import type { TurnPresentation } from "@/timeline/turn-liveness";
import type { PendingPermission } from "@/types/shared"; 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 { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card"; import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; 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 { 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 { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
@@ -88,6 +90,7 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { import {
createWorkspaceFileTabTarget, createWorkspaceFileTabTarget,
type WorkspaceFileLocation, type WorkspaceFileLocation,
@@ -148,9 +151,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: { function renderStreamItemWithTurnFooter(input: {
content: ReactNode; content: ReactNode;
layoutItem: StreamLayoutItem; layoutItem: StreamLayoutItem;
turnIndent: TurnIndent;
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
supportsTimelineCursor: boolean; supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler; onForkAssistantTurn?: AssistantTurnForkHandler;
@@ -171,7 +181,7 @@ function renderStreamItemWithTurnFooter(input: {
/> />
) : null; ) : null;
const content = ( const content = (
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}> <StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
{input.content} {input.content}
</StreamItemWrapper> </StreamItemWrapper>
); );
@@ -333,7 +343,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning); 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 chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth); const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null); const viewportRef = useRef<StreamViewportHandle | null>(null);
@@ -357,6 +367,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>( const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(), new Set(),
); );
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided) // Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? ""; const resolvedServerId = serverId ?? context.serverId ?? "";
@@ -421,6 +432,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setIsNearBottom(true); setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set()); setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set()); setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]); }, [agentId]);
const isActive = useRetainedPanelActive(); const isActive = useRetainedPanelActive();
@@ -448,6 +460,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files", view: "files",
}); });
}); });
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo( const inlinePathOpener = useMemo(
() => () =>
createInlinePathOpener({ createInlinePathOpener({
@@ -455,8 +468,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client, client,
openFile: openInlineFile, openFile: openInlineFile,
openDirectory: openInlineDirectory, openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}), }),
[workspaceRoot, client, openInlineFile, openInlineDirectory], [workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
); );
useEffect(() => { useEffect(() => {
if (!isActive) inlinePathOpener.cancel(); if (!isActive) inlinePathOpener.cancel();
@@ -512,16 +526,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
tail: effectiveStreamItems, tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem, transform: transformTimelineItem,
level: toolCallDetailLevel, display: chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive, isTurnActive,
activeTurnId: effectiveTurnPresentation.turnId,
}), }),
[ [
presentStream, presentStream,
effectiveStreamItems, effectiveStreamItems,
effectiveStreamHead, effectiveStreamHead,
transformTimelineItem, transformTimelineItem,
toolCallDetailLevel, chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive, isTurnActive,
effectiveTurnPresentation.turnId,
], ],
); );
const { const {
@@ -563,12 +583,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive, isTurnActive,
history: baseRenderModel.history, history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead, liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, timingByItemId: baseRenderModel.turnTiming.byItemId,
}), }),
[ [
baseRenderModel.history, baseRenderModel.history,
baseRenderModel.segments.liveHead, baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId, baseRenderModel.turnTiming.byItemId,
isTurnActive, isTurnActive,
streamRenderStrategy, streamRenderStrategy,
], ],
@@ -651,6 +671,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[streamRenderStrategy], [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) => { const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => { setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous); const next = new Set(previous);
@@ -665,12 +722,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderUserMessageItem = useCallback( const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => { (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return ( const collapsedTurn = getCollapsedTurn(item.id);
const message = (
<UserMessage <UserMessage
serverId={resolvedServerId} serverId={resolvedServerId}
agentId={agentId} agentId={agentId}
messageId={item.messageId} messageId={item.messageId}
message={item.text} message={item.text}
sender={item.sender}
images={item.images} images={item.images}
attachments={item.attachments} attachments={item.attachments}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
@@ -684,8 +743,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( const renderAssistantMessageItem = useCallback(
@@ -800,10 +876,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
presentation.groupsByHostId.get(hostId), presentation.groupsByHostId.get(hostId),
); );
const renderToolCallItem = useCallback( const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => { (
layoutItem: StreamLayoutItem,
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
) => {
const group = getToolCallGroup(item.id); const group = getToolCallGroup(item.id);
if (!group) { if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); return item.kind === "thought"
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
} }
const expanded = expandedToolCallGroupIds.has(group.run.id); const expanded = expandedToolCallGroupIds.has(group.run.id);
return ( return (
@@ -816,10 +897,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{expanded {expanded
? group.run.calls.map((call, index) => ( ? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}> <React.Fragment key={call.id}>
{renderSingleToolCallItem( {call.kind === "thought" ? (
call, // Reasoning is grouped only while Expand thinking is off, so an opened
index === group.run.calls.length - 1, // group shows each thought as a closed row like any other call.
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, <ThoughtSlot
itemId={call.id}
text={call.text}
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
)} )}
</React.Fragment> </React.Fragment>
)) ))
@@ -829,6 +923,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, },
[ [
expandedToolCallGroupIds, expandedToolCallGroupIds,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup, getToolCallGroup,
renderSingleToolCallItem, renderSingleToolCallItem,
setToolCallGroupExpanded, setToolCallGroupExpanded,
@@ -846,10 +942,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderAssistantMessageItem(layoutItem, item); return renderAssistantMessageItem(layoutItem, item);
case "thought": case "thought":
return renderThoughtItem(layoutItem, item); return renderToolCallItem(layoutItem, item);
case "tool_call": case "tool_call":
return renderToolCallItem(layoutItem, item); return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
case "notification": case "notification":
return <Notification level={item.level} message={item.message} />; return <Notification level={item.level} message={item.message} />;
@@ -879,8 +985,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId, agentId,
renderUserMessageItem, renderUserMessageItem,
renderAssistantMessageItem, renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem, renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId, resolvedServerId,
], ],
); );
@@ -893,12 +1000,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderStreamItemWithTurnFooter({ return renderStreamItemWithTurnFooter({
content, content,
layoutItem, layoutItem,
turnIndent: getTurnIndent(layoutItem),
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
supportsTimelineCursor: supportsAgentForkContextCursor, supportsTimelineCursor: supportsAgentForkContextCursor,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn, onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
}); });
}, },
[ [
getTurnIndent,
handleForkAssistantTurn, handleForkAssistantTurn,
readOnly, readOnly,
renderStreamItemContent, renderStreamItemContent,
@@ -1060,13 +1169,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled = const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0; expandedInlineToolCallIds.size === 0;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo( const historyRowRevision = useMemo(
() => ({ () => ({
contentById: presentation.historyGroupUpdatesByHostId, contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds, displayStateById: {
has: (id: string) =>
expandedToolCallGroupIds.has(id) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile, globalDisplayState: isMobile,
}), }),
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId], [
expandedToolCallGroupIds,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
],
); );
const findItems = useMemo( const findItems = useMemo(
@@ -1080,13 +1201,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch} epoch={timelineEpoch}
items={findItems} items={findItems}
viewportRef={viewportRef} viewportRef={viewportRef}
revealLoadedMessage={revealLoadedHistory} revealLoadedMessage={revealLoadedMessage}
visibleMessageIds={mountedMessageIds} visibleMessageIds={mountedMessageIds}
> >
<ToolCallSheetProvider> <ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}> <AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing> <MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({ {streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId, agentId,
segments: renderModel.segments, segments: renderModel.segments,
historyRowRevision, historyRowRevision,
@@ -1763,15 +1885,40 @@ const permissionStyles = StyleSheet.create((theme) => ({
})); }));
interface StreamItemWrapperProps { interface StreamItemWrapperProps {
itemId: string;
gapBelow: number; gapBelow: number;
turnIndent: TurnIndent;
children: ReactNode; children: ReactNode;
} }
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) { function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo( 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":
case "openedHost":
return (
<View style={wrapperStyle}>
{children}
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
</View>
);
case "nested":
return (
<View style={wrapperStyle}>
<IndentRail style={gapStyle}>{children}</IndentRail>
</View>
);
case "lastNested":
return (
<View style={wrapperStyle}>
<IndentRail>{children}</IndentRail>
</View>
);
}
} }
@@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
} }
describe("findMountedWindowStart", () => { describe("findMountedWindowStart", () => {
it("mounts complete autonomous runs and keeps same-run steers together", () => {
const items = [
{ ...userMessage("human", 1), turnId: "first" },
{ ...assistantMessage("reply", 2), turnId: "first" },
{ ...toolCall("wake", 3), turnId: "autonomous" },
{ ...userMessage("steer", 4), turnId: "autonomous" },
{ ...assistantMessage("done", 5), turnId: "autonomous" },
];
expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2);
});
it("keeps all items mounted when the chat is below the threshold", () => { it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)]; const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
+2
View File
@@ -72,6 +72,7 @@ import {
type StartupBlocker, type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap"; } from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
import { ThemedStack } from "@/navigation/themed-stack"; import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation"; import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -759,6 +760,7 @@ function OpenProjectListener() {
} }
function AppWithSidebar({ children }: { children: ReactNode }) { function AppWithSidebar({ children }: { children: ReactNode }) {
useSettingsRouteEntry();
const pathname = usePathname(); const pathname = usePathname();
const hosts = useHosts(); const hosts = useHosts();
const storeReady = useStoreReady(); const storeReady = useStoreReady();
@@ -5,6 +5,7 @@ import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks"; import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
@@ -87,11 +88,7 @@ function HostAgentReadyRouteContent() {
]); ]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated(); const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => { useDirectoryDemand(serverId);
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({ const resolution = resolveAgentRoute({
hasHydratedWorkspaces, hasHydratedWorkspaces,
hasHydratedLayout, hasHydratedLayout,
@@ -9,6 +9,7 @@ import {
useActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -110,6 +111,10 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(params.open); 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(() => { useEffect(() => {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return; return;
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => { openDirectory: (directory) => {
opened.push(directory); opened.push(directory);
}, },
openOutsideDirectory: (directory) => {
opened.push(directory);
},
}); });
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred"); const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred"); await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => { openDirectory: (directory) => {
opened.push(directory); opened.push(directory);
}, },
openOutsideDirectory: (directory) => {
opened.push(directory);
},
}); });
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred"); const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel(); opener.cancel();
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
await pending; await pending;
expect(opened).toEqual([]); expect(opened).toEqual([]);
}); });
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,12 +14,23 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null; client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
} }
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string }; type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
/** Resolve workspace folders from filesystem facts, including names that contain dots. */ /** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> { export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) { if (!input.file) {
return { kind: "directory", directoryPath: input.directory }; return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
} }
if ( if (
input.lineStart !== undefined || input.lineStart !== undefined ||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"]; client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void; openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) { }) {
let generation = 0; let generation = 0;
return { return {
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return; if (ownGeneration !== generation) return;
if (destination.kind === "directory") { if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath); input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) { } else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({ const location = normalizeWorkspaceFileLocation({
path: normalized.file, path: normalized.file,
@@ -112,8 +112,9 @@ const ContextWindowRing = withUnistyles(function ContextWindowRing({
width={size} width={size}
height={size} height={size}
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
accessibilityElementsHidden {...(isNative
importantForAccessibility="no-hide-descendants" ? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
: { "aria-hidden": true })}
> >
<Circle <Circle
cx={center} cx={center}
@@ -77,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory"; import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory";
const SORT_OPTIONS: { value: SortOption }[] = [ const SORT_OPTIONS: { value: SortOption }[] = [
@@ -450,9 +451,15 @@ export function FileExplorerPane({
isLocalExecution: isLocalDaemon, isLocalExecution: isLocalDaemon,
}); });
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const projectId = useWorkspaceFields(
serverId,
workspaceId ?? null,
(workspace) => workspace.projectId,
);
const openDirectoryInEditor = useOpenDirectoryInEditor({ const openDirectoryInEditor = useOpenDirectoryInEditor({
serverId, serverId,
workspaceDirectory: normalizedWorkspaceRoot, workspaceDirectory: normalizedWorkspaceRoot,
projectId,
}); });
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
const fsEntryOpsEnabled = useSessionStore( const fsEntryOpsEnabled = useSessionStore(
@@ -0,0 +1,4 @@
import type { ImportSessionListComponent } from "./import-session-list.types";
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
export const ImportSessionList: ImportSessionListComponent | null = null;
@@ -0,0 +1,13 @@
import type { ReactElement, ReactNode } from "react";
export interface ImportSessionListProps<Item> {
data: ReadonlyArray<Item>;
keyExtractor: (item: Item) => string;
renderItem: (item: Item) => ReactNode;
header: ReactElement | null;
footer: ReactElement | null;
}
export type ImportSessionListComponent = <Item>(
props: ImportSessionListProps<Item>,
) => ReactElement;
@@ -0,0 +1,141 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
import type { ImportSessionListProps } from "./import-session-list.types";
const SCROLLER_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
overscrollBehavior: "contain",
};
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
export function ImportSessionList<Item>({
data,
keyExtractor,
renderItem,
header,
footer,
}: ImportSessionListProps<Item>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const indexByKey = useMemo(
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
[data, keyExtractor],
);
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (focusedIndex === undefined) return visible;
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
(index) => index >= 0 && index < data.length,
);
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
},
[data.length, focusedIndex],
);
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
const row = event.target.closest<HTMLElement>("[data-session-key]");
setFocusedKey(row?.dataset.sessionKey ?? null);
}, []);
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
}, []);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height);
}, []);
const getScrollElement = useCallback(() => scrollRef.current, []);
const getItemKey = useCallback(
(index: number) => keyExtractor(data[index]!),
[data, keyExtractor],
);
const estimateSize = useCallback(() => 88, []);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement,
getItemKey,
estimateSize,
scrollMargin: headerHeight,
overscan: 5,
rangeExtractor,
});
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
event.defaultPrevented ||
!(target instanceof HTMLElement) ||
!event.currentTarget.contains(target) ||
target.isContentEditable ||
target.closest("input, textarea, select")
)
return;
if (event.key === "End") {
event.preventDefault();
// Reconcile the landing position as variable-height rows are measured.
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
} else if (event.key === "Home") {
event.preventDefault();
virtualizer.scrollToOffset(0);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
// Native page-scroll animations can keep moving after Home or End.
const direction = event.key === "PageDown" ? 1 : -1;
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
}
},
[data.length, virtualizer],
);
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
const rowStyle = useCallback(
(offset: number): CSSProperties => ({
...CONTENT_STYLE,
position: "absolute",
top: 0,
transform: `translateY(${offset}px)`,
}),
[],
);
return (
<div
ref={scrollRef}
style={SCROLLER_STYLE}
data-testid="import-session-list"
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onKeyDown={handleKeyDown}
>
<View onLayout={handleHeaderLayout}>{header}</View>
<div style={contentStyle}>
{virtualRows.map((row) => (
<div
key={row.key}
data-index={row.index}
data-session-key={row.key}
ref={virtualizer.measureElement}
style={rowStyle(row.start - headerHeight)}
>
{renderItem(data[row.index]!)}
</div>
))}
</div>
<div>{footer}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,6 +11,7 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ImportSessionList } from "@/components/import-session-list";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
@@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) {
return <Text style={styles.rowMeta}>{label}</Text>; return <Text style={styles.rowMeta}>{label}</Text>;
} }
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
return `${entry.providerId}:${entry.providerHandleId}`;
}
function hasListHeader(
input: SheetStatusMessagesProps & {
showFilter: boolean;
hasProviderErrors: boolean;
},
): boolean {
return (
input.showFilter ||
!input.isClientReady ||
input.isSnapshotUnsupported ||
input.hasNoImportableProviders ||
(input.isLoadingSessions && !input.hasRows) ||
input.importErrored ||
input.hasProviderErrors
);
}
function SessionRows({ function SessionRows({
visible,
onClose,
header,
listHeader,
listFooter,
serverId, serverId,
entries, entries,
disabled, disabled,
@@ -379,6 +406,11 @@ function SessionRows({
resolveFolder, resolveFolder,
onImportSession, onImportSession,
}: { }: {
visible: boolean;
onClose: () => void;
header: SheetHeader;
listHeader: ReactNode;
listFooter: ReactNode;
serverId: string | null; serverId: string | null;
entries: ReadonlyArray<FetchRecentProviderSessionEntry>; entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
disabled: boolean; disabled: boolean;
@@ -386,20 +418,69 @@ function SessionRows({
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void; onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) { }) {
// Keep short results content-sized; only long lists need a bounded viewport.
const virtualized = ImportSessionList !== null && entries.length > 8;
const renderSessionRow = useCallback(
(entry: FetchRecentProviderSessionEntry) => (
<ImportSessionSheetRow
key={getSessionRowKey(entry)}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === getSessionRowKey(entry)}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
),
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
);
const renderItem = useCallback(
(item: FetchRecentProviderSessionEntry) => (
<View style={styles.listRow}>{renderSessionRow(item)}</View>
),
[renderSessionRow],
);
const virtualHeader = useMemo(
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
[listHeader],
);
const virtualFooter = useMemo(
() => <View style={styles.listFooter}>{listFooter}</View>,
[listFooter],
);
return ( return (
<View style={styles.list}> <AdaptiveModalSheet
{entries.map((entry) => ( visible={visible}
<ImportSessionSheetRow onClose={onClose}
key={`${entry.providerId}:${entry.providerHandleId}`} header={header}
serverId={serverId} testID="import-session-sheet"
entry={entry} desktopMaxWidth={560}
disabled={disabled} desktopHeight={virtualized ? "85%" : undefined}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`} snapPoints={IMPORT_SHEET_SNAP_POINTS}
folder={resolveFolder(entry)} scrollable={!virtualized}
onImportSession={onImportSession} contentStyle={virtualized ? styles.listBody : undefined}
/> >
))} {ImportSessionList && virtualized ? (
</View> <View style={styles.list}>
<ImportSessionList
data={entries}
keyExtractor={getSessionRowKey}
renderItem={renderItem}
header={virtualHeader}
footer={virtualFooter}
/>
</View>
) : (
<>
{listHeader}
{entries.length > 0 ? (
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
) : null}
{listFooter}
</>
)}
</AdaptiveModalSheet>
); );
} }
@@ -726,85 +807,124 @@ export function ImportSessionSheet({
}); });
const showFilter = filterProviders.length > 1; const showFilter = filterProviders.length > 1;
const showLoadMore = hasMoreSessions(queries, pageLimit); const showLoadMore = hasMoreSessions(queries, pageLimit);
const showListHeader = hasListHeader({
showFilter,
isClientReady: Boolean(client),
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows: visibleEntries.length > 0,
importErrored: importMutation.isError,
hasProviderErrors: providerErrorRows.length > 0,
});
const listHeader = useMemo(
() =>
showListHeader ? (
<View style={styles.listHeaderContent}>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
</View>
) : null,
[
showListHeader,
showFilter,
filterTriggerStyle,
handleFilterOpen,
selectedProvider,
theme.colors.foregroundMuted,
SelectedProviderIcon,
selectedProviderLabel,
filterComboboxOptions,
handleFilterSelect,
renderFilterOption,
isFilterOpen,
client,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
visibleEntries.length,
importMutation.isError,
providerErrorRows,
handleRetryProvider,
],
);
const listFooter = useMemo(
() => (
<>
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</>
),
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
);
return ( return (
<AdaptiveModalSheet <SessionRows
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
header={header} header={header}
testID="import-session-sheet" listHeader={listHeader}
desktopMaxWidth={560} listFooter={listFooter}
snapPoints={IMPORT_SHEET_SNAP_POINTS} serverId={serverId}
> entries={visibleEntries}
{showFilter ? ( disabled={importMutation.isPending}
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}> importingSessionKey={importingSessionKey}
<Pressable resolveFolder={resolveFolder}
onPress={handleFilterOpen} onImportSession={handleImportSession}
style={filterTriggerStyle} />
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
{visibleEntries.length > 0 ? (
<SessionRows
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
) : null}
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
); );
} }
@@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
listBody: {
gap: 0,
},
list: { list: {
flex: 1,
minHeight: 0,
marginHorizontal: -theme.spacing[2],
},
listHeader: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
listHeaderContent: {
gap: theme.spacing[4],
},
smallList: {
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
listFooter: {
marginHorizontal: theme.spacing[2],
},
listRow: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
footer: { footer: {
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
alignItems: "center", alignItems: "center",
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
* expanded tool cards stay in place, with a gap between their border and the rail.
*/
const RAIL_LEFT = 1;
const RAIL_WIDTH = 1;
export const INDENT_RAIL_CONTENT_OFFSET = 21;
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
export function IndentRail({
children,
style,
}: {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
return <View style={[styles.rail, style]}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({
rail: {
marginLeft: RAIL_LEFT,
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
borderLeftWidth: RAIL_WIDTH,
borderLeftColor: theme.colors.border,
},
}));

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