Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 72b9ea1652 test(app): retry sidebar order after reload layout
Treat missing row geometry as a transient poll result so reload assertions wait for every visible sidebar item to lay out instead of failing before the matcher can retry.
2026-09-02 15:38:11 +02:00
Mohamed Boudra 7838b534a8 Show self-hosted and hosted Hub plans (#4213)
* feat(website): publish Hub pricing options

Present self-hosted and hosted Hub choices together while reading the hosted offer from Hub's public catalog.

* fix(website): require a valid hosted offer

* fix(website): preserve Hub page on catalog failure
2026-09-02 15:05:28 +02:00
Mohamed Boudra ff0171cf7b Make agent tab tooltips compact and informative (#4214)
Join and truncate prompt-derived titles for the visual tooltip, add agent ID and live human activity metadata on a second row, and keep activity subscriptions local to the open tooltip. Preserve the full normalized title as the accessible tab name and cover the behavior through Playwright.
2026-09-02 14:27:18 +02:00
Mohamed Boudra 77aff0f4fb fix(app): stabilize Mermaid streaming lifecycle (#4210)
Drive the streaming assertion from the actual turn lifecycle so iframe replacement at completion is not mistaken for diagram loss. Preserve the cached timeline range during reload reconciliation so a projected message spanning the cache cursor replaces its partial prefix instead of being appended to it.
2026-09-02 13:37:32 +02:00
Mohamed Boudra 708f9260b1 fix(server): disable repository fsmonitor commands (#4208)
A repository-controlled core.fsmonitor command can execute when the daemon refreshes Git state. Opening or reconciling a malicious checkout therefore allows code execution as the daemon user. Force core.fsmonitor=false on daemon-owned Git invocations before repository config is considered.
2026-09-02 13:15:51 +02:00
Mohamed Boudra e34aea206e feat(plugins): add live timeline rows and client slash commands
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer.

Related to #3940 and #3952.
2026-09-02 13:12:09 +02:00
Mohamed Boudra 49784fac15 Keep Changes and working diff comparisons in sync (#4199)
* fix(app): keep Changes comparison and diff in sync

Own the working comparison at the workspace checkout so the Changes tree and adjacent working diff share it. Immutable commit diffs remain scoped to their commit SHA.

* docs(app): align working diff ownership notes
2026-09-02 12:38:16 +02:00
Mohamed Boudra 4aab00f267 fix(desktop): use CLI shim for packaged Linux installs (#4207)
Non-AppImage packages install the Electron executable at a stable path, but invoking it through the CLI symlink cannot restart the daemon. Link the bundled CLI shim for those targets while preserving AppImage links.\n\nFixes #4184
2026-09-02 12:30:28 +02:00
Mohamed Boudra de8535c38b Customize top-level sidebar items (#4203)
* feat(app): customize top-level sidebar items

Let users reorder and hide built-in and plugin sidebar rows from Appearance settings while keeping the preference device-local. Add Search as a first-class row and standardize section explanations as header info tooltips.

* fix(app): preserve consecutive sidebar preference edits

Derive each sidebar change from the latest cached settings and retain unavailable plugin slots while editing the visible list. Keep E2E interaction selectors tied to accessible control names.

* test(app): isolate provider settings unit boundary

Keep the legacy provider-section test focused on provider behavior now that its shared section shell can render the settings info control.
2026-09-02 11:42:39 +02:00
Mohamed Boudra d67a6f5f6c fix(release): keep releases draft until manifests are ready (#4201)
Create tag-triggered GitHub releases as drafts and publish them only after the desktop updater manifests are complete, stamped, and uploaded. Preserve beta prerelease semantics and safe retry behavior.\n\nCloses #4183
2026-09-02 11:29:41 +02:00
paseo-ai[bot] 78f7796eb4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-02 00:19:07 +00:00
Mohamed Boudra 9400a49af6 chore(release): cut 0.7.2 2026-09-02 02:07:34 +02:00
Mohamed Boudra e9612df862 docs: add 0.7.2 changelog 2026-09-02 01:57:25 +02:00
Mohamed Boudra 8dd4da6ee0 Keep mobile streaming and panel motion stable under JS stalls (#4190)
* fix(app): activate mobile panels after motion settles

Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion.

* perf(app): re-render only changed history rows

Every coalesced stream tick re-rendered all mounted history rows because the inverted FlatList recreates each cell with a new index and ref. Keep layout item identity for unchanged rows and memoize the row on it, and resolve tool-call groups through a stable event so the render callbacks stop churning.

* perf(app): count replica row bytes once

Budget accounting measured every stored row of a touched host on each persist through the Buffer polyfill. Cache the UTF-8 length per immutable row and count without materialising bytes.

* fix(app): settle mobile panels on position

Activity now follows the UI-thread position reaching the canonical anchor instead of an animation completion callback. A successful gesture keeps its finishing motion and the matching command adopts it; settlement and command acceptance may arrive in either order.

* fix(app): keep settled panel props across JS stalls

Turn off Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS. Its settled-props collector drops entries older than two seconds before React receives them, so a JS stall across a settle left the overlay display and backdrop opacity reverted on the next commit while the store held the new panel.

* fix(app): reconcile hidden keyboard end on iOS

Only a hidden end event resets the keyboard shift. A late visible end no longer resurrects a keyboard that already dismissed.

* fix(app): focus the replacement input through autoFocus after clearing

Clearing swaps the native input for a fresh instance. Focusing the doomed instance asked Android for the keyboard and then tore the focused view down, which cancelled the show; a JS focus command sent alongside the remount reached a view that was not attached yet and the IME ignored it. Focus and blur requested while the replacement is pending now decide the replacement's autoFocus, which native applies once the view is attached.

* fix(app): revise history rows in every viewport

The history row memo boundary bails out until its item identity changes, but only the native viewport cloned rows flagged by historyRowRevision. On web and desktop a history host whose tool-call group kept updating from the live head rendered stale. Move the revision into useRevisedHistoryRows and run both viewports' history through it. Regression test renders the web strategy with a memoized row and asserts only the revised host re-renders.
2026-09-02 00:52:44 +02:00
Mohamed Boudra 3998ea11d9 Keep large diffs responsive across web and mobile (#4174)
* perf(app): keep large diffs responsive

Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files.

* test(app): assert canvas diff header accessibility

* test(app): assert canvas diff header semantics
2026-09-01 23:52:11 +02:00
Mohamed Boudra ba54575ab7 Show completion times for hidden-prompt turns (#4170)
System-injected prompts have canonical turns but no visible user row, so completed timing was discarded despite an exact end timestamp. Preserve completion metadata with an explicitly unknown duration so the footer can show the truthful timestamp.
2026-09-01 23:24:20 +02:00
Mohamed Boudra 3e59adb4dc Add Claude Fable 5.1 to the model catalog (#4179)
* feat(claude): add Fable 5.1 model

Expose the new 1M-context model through the curated Claude catalog and normalize its dated and context-suffixed runtime IDs back to one canonical entry.

* fix(claude): group Fable models in catalog

* chore(release): cut 0.7.1

* fix(nix): refresh 0.7.1 dependency hash

* test(cli): follow package version in plugin scaffold

* test(cli): keep Claude catalog checks extensible
2026-09-01 21:51:51 +02:00
Mohamed Boudra 640b456915 docs: add 0.7.1 changelog 2026-09-01 20:59:27 +02:00
Mohamed Boudra dd5e8f95b1 Fix graceful daemon shutdown on Windows (#4168)
* test(server): probe supervised Windows cleanup

* test(server): report both cleanup failures

* fix(server): gracefully stop supervised workers
2026-09-01 19:59:03 +02:00
Mohamed Boudra 59661f2bb8 Eliminate recurring cross-platform CI flakes (#4171)
* fix(ci): eliminate recent cross-platform flakes

Synchronize tests on owned state instead of elapsed time or transient identities. Preserve file rename collision safety when platform file IDs are unavailable.

* fix(ci): close follow-up synchronization gaps

Keep fetches in flight through buffered-event classification, scope mock agents to navigation tests, and wait for live workspace hydration before opening agent routes.

* fix(ci): synchronize browser lifecycle boundaries
2026-09-01 19:25:16 +02:00
Mohamed Boudra 06fae60809 Prevent oversized assistant messages from crashing the timeline (#4166)
* fix(app): cap oversized assistant messages

A single assistant message can expand into enough rendered Markdown nodes to crash the renderer. Limit the Markdown input while preserving the complete timeline value and report its full byte size.

* fix(app): bound oversized stream processing

Cap the render input before paced reveal and defer full-message byte counting until completion. Reuse the existing grapheme boundary policy so capped text never ends in a partial glyph.

* fix(app): preserve capped text without segmentation

Hermes lacks Intl.Segmenter, while the shared reveal boundary intentionally returns zero without it. Keep that contract and fall back locally to a surrogate-safe 32K prefix.
2026-09-01 18:06:25 +02:00
JunsandMohamed Boudra 95575ad05a fix(omp): honor configured timeout while waiting for ready (#4143)
* fix(omp): honor configured timeout while waiting for ready

* fix(omp): increase ready timeout to 20 seconds

* docs: keep OMP timeout policy in one place

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-01 17:19:56 +02:00
Mohamed Boudra 7ea9286aa4 fix(app): keep agents running when image viewer closes (#4161)
The shared image lightbox handled Escape after the global shortcut dispatcher had already routed the same key to agent interruption. Register the viewer with the established web overlay owner so dismissal takes first refusal while ordinary Escape interruption remains intact.
2026-09-01 16:17:47 +02:00
Mohamed Boudra 07125d2c02 Let Git plugins prepare themselves during installation (#4158)
* feat(plugins): support prepared Git installations

Let Git plugins declare explicit preparation commands without making package installation the default. Keep installation transactional, make plugin trust explicit, and use one canonical source reference for monorepos.

* fix(plugins): parse Windows source references
2026-09-01 16:12:23 +02:00
Mohamed Boudra 0c38749c38 fix(server): rewind paginated Codex threads (#4119) 2026-09-01 07:43:47 +02:00
Matt Cowgerandpaseo-bot[bot] ecec33265e feat(pi): add active-turn steering via native steer RPC (#3752)
* feat(pi): add active-turn steering via native steer RPC

* fix(pi): clear queued steering before interrupt

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-31 21:10:27 +02:00
paseo-ai[bot] 22463bb7e5 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-31 17:36:52 +00:00
Mohamed Boudra c56638ea8c chore(release): cut 0.7.0 2026-08-31 19:25:32 +02:00
Mohamed Boudra 185ab3f300 docs: prepare v0.7.0 changelog 2026-08-31 19:20:58 +02:00
Matt Cowger 1694828275 fix(opencode): show child session prompts (#4055)
Show OpenCode child-session user prompts even when the text part has no completion timestamp.
2026-08-31 18:35:28 +02:00
74a377ff6a fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE (#4048)
* fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE

A dead Claude/OMP child currently turns stdin.write() EPIPE into
uncaughtException and kills the daemon. Catch the write, listen for
stdin errors, and close that transport instead.

Closes #4047

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(server): reproduce JSONL RPC stdin EPIPE

* test(server): gate native EPIPE fixture on POSIX

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-31 15:35:34 +02:00
Mohamed Boudra 63923fdd07 Keep Android timeline still when tapping text (#4090)
* fix(android): keep timeline still when tapping text

Selectable Android text takes touch focus. React Native then scrolls focused children using untransformed coordinates, which moves inverted timelines even though the user did not scroll.\n\nSkip that auto-scroll only for touch-focused, selectable, non-editable text in inverted vertical scroll views, preserving normal focus navigation and editable inputs.

* test(e2e): isolate PR pane fixtures from bots
2026-08-31 12:10:40 +02:00
Mohamed Boudra 11e76f6655 fix(server): resume agents cleanly after failed turns
Track each foreground run start as pending, started, or failed so a previous turn error cannot reject a newly accepted prompt. Includes a regression for resuming after a failed turn.
2026-08-31 12:10:34 +02:00
Mohamed Boudra 4a3e6dea63 Increase mobile content text to 16px (#4111)
Keep mobile interface chrome at 15px while defaulting readable content to 16px. Migrate existing mobile 15px content preferences once while preserving later explicit choices.
2026-08-31 11:58:42 +02:00
paseo-ai[bot] f35d860725 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-31 08:17:46 +00:00
Mohamed Boudra 95f4eb7577 chore(release): cut 0.7.0-beta.3 2026-08-31 09:52:59 +02:00
Mohamed Boudra fcd81f591e chore(release): carry forward 0.7.0-beta.2 version 2026-08-31 09:46:56 +02:00
Mohamed Boudra b0fe4efc8c docs: add v0.7.0-beta.3 changelog 2026-08-31 09:45:26 +02:00
Mohamed Boudra f2f93087ed docs(website): refresh product comparisons
Reframe the pages around verified product differences while emphasizing local ownership, privacy, extensibility, and deployment flexibility.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 73a5e6c63e feat(website): expand homepage product story 2026-08-30 23:48:54 +02:00
Mohamed Boudra fa53ba8f93 feat(website): refine responsive product mockups
Keep desktop and mobile scenes visually consistent while improving phone depth, diff realism, alignment, and interaction polish.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 375e9b3e53 feat(website): rebuild homepage product mockups
Replace static captures with live, responsive scenes that demonstrate building, reviewing, shipping, and extending work across desktop and mobile.
2026-08-30 23:48:54 +02:00
Mohamed Boudra adf80a9fe0 Keep Hub daemon access explicit (#4088)
* feat(hub): make daemon authority explicit

Connecting a daemon establishes presence without granting execution. Hub authority now uses semantic session permissions, with workflow execution granted explicitly and inspectable through the normal session protocol.

Attention frames remain part of the existing agent and terminal subscriptions, keeping session output solicited.

* test(server): match sentence-case tool labels
2026-08-30 22:46:24 +02:00
Mohamed Boudra 8533c5c06f fix(app): keep expanded composer above keyboard (#4051)
Allow the composer constraint chain to shrink inside KeyboardDock so overlong drafts scroll internally without hiding controls behind the IME. Extend the Android keyboard invariant to cover the maximum-height case.
2026-08-30 18:19:52 +02:00
Mohamed Boudra b1050d9bed feat(cli): simplify Hub login and export triggers (#4050) 2026-08-30 14:11:19 +02:00
Mohamed Boudra 5e561f8a23 fix(android): release communication route after dictation (#4069)
Stopping capture abandoned audio focus but left Android's communication mode and selected device owned by Paseo, which could keep Bluetooth audio on its narrow-band call route without an active microphone. Release and reacquire the complete route with the audio session, and prevent idle device callbacks from restoring it.
2026-08-30 11:42:29 +02:00
Mohamed Boudra ce335de1cd Make Paseo tool calls readable (#4066)
Present Paseo-owned prompts, inputs, and results as readable provider-neutral sections with sentence-case labels, backed by focused tests and real-provider UI verification.
2026-08-30 11:02:29 +02:00
Mohamed Boudra 6688c92bc6 fix(app): stop calling new agent work reopened (#4068)
Task snapshots reuse identities after completed work, so backward status transitions do not prove that an old task resumed. Report active work as started and keep pending work in the current task snapshot instead of inventing a reopened event.
2026-08-30 10:55:19 +02:00
Mohamed Boudra 8cf5942a5f fix(app): keep web composer focused after submit (#4067)
The keyboard-sync change needed submission blur to dismiss the native keyboard, but applying it to every platform interrupted consecutive keyboard-driven messages on web and desktop. Keep submission blur native-only.
2026-08-30 10:50:20 +02:00
Mohamed Boudra 9113542c5a fix(dictation): finalize recordings with abandoned partials (#4065)
Mid-stream silence clearing can leave a non-final partial whose segment will never commit. Treat those partials as UI-only once all requested commits settle so retries of the same recording can complete instead of timing out forever.
2026-08-30 10:29:25 +02:00
Mohamed Boudra 2618686677 fix(app): stabilize image lightbox gestures (#4064)
Android reports one terminal pinch update with a stale two-pointer count after a finger has lifted. Track the real touch lifecycle so that shifted focal point cannot move the image on release.\n\nKeep initial presentation padding separate from the full zoom canvas, and make double-tap reset and outside-image dismissal consistent across native and web.
2026-08-30 09:15:20 +02:00
Mohamed Boudra 86ef468003 fix(app): make image lightbox gestures work on Android (#4049)
Chat image previews render in a native Modal outside the app gesture root, so their controls worked while pinch gestures never activated. Give native lightboxes their own gesture root, keep the web modal on a normal view root, and ignore Android's terminal one-pointer pinch update to prevent release jumps.
2026-08-29 23:46:20 +02:00
yz 0dad1d7a61 fix(app): use popout scrolling on iPad desktop (#3992)
Fix native wide-layout model selectors mounting bottom-sheet scrolling outside a bottom sheet. Keep the scrolling policy with the model browser and cover native compact, native wide, and web surfaces.
2026-08-29 23:10:47 +02:00
Mohamed Boudra 63587a9c03 feat(server): establish semantic session permissions (#3981)
Keep trusted-client and Hub behavior stable while replacing RPC-name grants with a semantic authorization boundary. This establishes the operation inventory needed for future principal, pairing, and resource-scoped access work.
2026-08-29 22:40:20 +02:00
LiteandClaude Fable 5 a88aa49f4c fix(quota): show Grok unified-billing usage as a weekly window (#4029)
xAI moved Grok accounts to unified billing. The plain /v1/billing
response now returns a zeroed legacy monthly shape (monthlyLimit.val 0),
which rendered as "0 credits left" with no bar in Settings -> Usage.

Fetch /v1/billing?format=credits — the same query the Grok CLI /usage
command uses — and map creditUsagePercent + currentPeriod onto a usage
window, matching the Claude/Codex weekly window convention. The legacy
monthly-credits balance path is unchanged; unified-billing responses
omit monthlyLimit/used, so it naturally yields no balance.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:20:20 +02:00
Mohamed Boudra eef9be8863 feat(app): add zoomable image previews (#4032)
Image files now zoom in place, while timeline images open the existing lightbox. A shared viewport extracted from Mermaid keeps fit, focal zoom, pan bounds, and consumer toolbar actions consistent across content types.
2026-08-29 22:08:17 +02:00
Mohamed Boudra 650f3ca7f6 Reuse OpenCode helpers across ordinary agent sessions (#4009)
* fix(opencode): reuse servers across agent sessions

Paseo's always-present per-agent environment forced every OpenCode session onto a dedicated helper, multiplying cold starts and memory use. Route ordinary sessions through one shared helper while a daemon-owned session bridge preserves exact environment and caller-scoped tools; keep custom process environment and MCP configuration isolated.

* fix(build): bundle the agent bridge from existing dependencies

The helper package duplicated Zod and changed the fixed-output desktop dependency graph even though its tool wrapper was identity-only. Use the server's existing Zod runtime directly so packaged daemons can materialize the bridge without extra traced dependencies.

* refactor(server): isolate provider runtime composition

Keep provider-specific lifecycle and tool-catalog wiring behind an agent-owned runtime boundary so the generic daemon bootstrap remains provider-agnostic. The runtime now owns bridge startup, snapshot injection, and idempotent cleanup.

* fix(desktop): prebundle the agent bridge plugin

Packaged daemons run from an application archive where esbuild cannot spawn its binary. Produce the self-contained plugin during the server build and make compiled runtimes read that artifact; source-mode tests retain a gated compiler fallback.
2026-08-29 22:08:03 +02:00
Mohamed Boudra b6bfdedf58 Fix archived agent navigation and recovery (#4033)
* fix(app): preserve archived agent navigation

Make explicit archived-agent opens one atomic layout command and defer them until persisted layout hydration. Use the provider's numeric active-session sentinel so recovery can complete.

* fix(app): preserve timeline resume during hydration

Paint persisted canonical rows with the current live head and retain their resume cursor. This prevents tail replay without dropping stream updates that arrive while replica storage loads.

* fix(app): serialize replica reads with cache writes
2026-08-29 22:07:26 +02:00
Omer Cohenandpaseo-bot[bot] 1d4c3a2722 feat(plugin): add host navigation capabilities for plugin surfaces (#3901)
* feat(plugin): add optional agent navigation capability

* feat(plugin): extend host navigation capability

* fix(plugin): preserve navigation tests after sdk rebase

* fix(plugin): scope host navigation contracts

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-29 21:33:09 +02:00
Mohamed Boudra 6c813d1a91 fix(app): keep chat synchronized with keyboard (#4044)
* fix(app): keep chat synchronized with keyboard

Dock the complete chat surface with worklet-driven layout padding so visual and native touch geometry move together. Reset cleared multiline native inputs and keep autocomplete portals anchored to the docked composer.

* fix(android): tolerate detached composer inputs

Return the nullable base input connection during IME restart instead of force-unwrapping it after the composer has blurred or unmounted.
2026-08-29 21:27:41 +02:00
Mohamed Boudra aeb240be98 fix(server): batch cold-start PR status polls (#4025)
The initial retained poll could run before repository identity discovery settled, routing every workspace through the legacy per-target path. Defer each unresolved target independently and rejoin the shared polling grid once its identity is ready.
2026-08-29 12:23:47 +02:00
Mohamed Boudra 7080648e17 docs(website): publish terms and privacy policies (#4030)
Separate the open-source app, optional relay, and hosted Hub so each service's obligations and data boundary are explicit.
2026-08-29 11:35:17 +02:00
Mohamed Boudra 463415ae84 fix(providers): tolerate slow Pi and OMP RPC startup (#4008)
MCP and extension initialization can keep provider control-plane requests pending beyond 30 seconds even when the response will arrive. Use a configurable 60-second provider default and include the active RPC phase in timeout errors.
2026-08-29 00:00:41 +02:00
Mohamed Boudra 693280d996 fix(app): keep commits above the safe area (#4005)
The bottom-anchored commits section did not consume the device inset, so Android system navigation covered its disclosure row. Keep the inset with the section that owns that edge.
2026-08-28 23:05:21 +02:00
Mohamed Boudra 3c6e4e39ec feat(highlight): support Astro source files (#3997)
Use one pure mixed-language parser for editor, source, and diff highlighting without pulling browser-only CodeMirror modules into the daemon.
2026-08-28 19:30:02 +02:00
Christoph Leiter ed628ff82e feat(app): add workspace actions to the command center (#3013)
Eight actions were only reachable through the sidebar workspace menu,
the workspace header menu, or a keybind you had to remember: Rename
workspace, Copy workspace path, Copy branch name, Pin/Unpin, the three
layout toggles, and Show setup. All eight are now in the command
center, plus a new one: label assignment. Type a label's name to
assign or unassign it on the active workspace without leaving the
keyboard.

Rename needed more than a palette entry. Its dialog lived inside the
sidebar row component, so it disappeared whenever that row was not
rendered: a collapsed project or status group, a collapsed Pinned
section, or focus mode. It now has a global host keyed on the active
workspace selection, mirroring use-global-workspace-pin-action.ts.

Each entry reuses the handler the existing menu item already calls, so
behaviour matches the menu. Entries that cannot act are omitted rather
than present and failing: Copy branch name without a branch, Pin when
the host lacks workspacePinning, Show setup when there is nothing to
show, and labels when the catalog has not loaded.

Toggle Explorer sidebar and Toggle focus mode are registered as
workspace contributions, not global ones. Their handlers live in
workspace-screen.tsx behind an isRouteFocused gate, so a global
registration would have listed two entries that close the palette and
do nothing on /settings, /sessions, /schedules and Home.

The copy-path, copy-branch, and rename-dialog logic was duplicated
across three sidebar files. It moves into one hook and one component so
the palette is not a fourth copy. That also fixes drift in
sidebar-status-list.tsx, where Copy branch name copied the workspace
name instead of the branch and four strings were hardcoded English.
This supersedes #3383, which fixes the same bug directly without the
shared-hook refactor.

Toggle focus mode also had a hidden duplicate: buildPaneContributions
already dispatches the same action once split panes are available, so
the standalone entry now steps aside there instead of listing the
toggle twice.

No new i18n keys.
2026-08-28 17:43:46 +02:00
667664847d Connect to remote daemons over SSH (#3989)
Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations.

Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits.

Closes #3949

Co-authored-by: James Strain <jstrain@nvidia.com>
Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
2026-08-28 16:32:12 +02:00
Mohamed Boudra 7ea4288117 Keep restored workspaces out of reconciliation loops (#3987)
* fix(app): stop workspace tab reconciliation loop

Duplicate startup tabs could move workspace focus into Explorer. Once hydration removed the stale agent, empty-workspace reconciliation kept seeding drafts into Explorer and retriggering itself. Preserve ordinary-pane focus at the reconciliation boundary.

* fix(app): normalize workspace focus before reconciliation

Persisted Explorer focus must not steer newly reconciled tabs into the sidebar before the output invariant is applied.
2026-08-28 15:50:44 +02:00
Mohamed Boudra 42b6d99d60 Wait for in-flight dictation commits before finishing (#3968)
* fix(dictation): preserve audio queued at stop

Stopping capture disabled native recording before the final microphone buffer had crossed the bridge, so accepting immediately could omit the end of a dictation. Wait for the native producer to publish its final buffer before completing stop, and serialize stop with restart and destruction.

* fix(voice): serialize capture lifecycle

A capture start already awaiting permission could resume after teardown, and repeated destroy calls did not share completion. Order capture lifecycle operations behind one promise and build the native module declarations before clean-CI typechecking.

* fix(dictation): drain queued web capture before finish

* fix(dictation): await in-flight segment commits

* Revert "fix(dictation): drain queued web capture before finish"

This reverts commit 5168a6554d.

* Revert "fix(voice): serialize capture lifecycle"

This reverts commit 00fd3e3556.

* Revert "fix(dictation): preserve audio queued at stop"

This reverts commit ec087dc9cd.
2026-08-28 15:29:42 +02:00
paseo-bot[bot] 23debf3af1 fix(server): honor configured commit signing (#3976)
Paseo forced signing off for commits created through the app and squash merges. Let Git apply the user's configured policy; the existing command timeout still bounds interactive signer waits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-28 19:24:56 +08:00
Mohamed Boudra 25eb322ef7 fix(app): prevent archived workspaces returning from cache (#3975)
Durable replica reads could race accepted directory replacements and restore rows that had already been removed in memory. Serialize reads behind pending writes and discard reads when the host changes in flight; local archives also suppress targeted cache restoration until removal is acknowledged.
2026-08-28 13:18:26 +02:00
Mohamed Boudra deb846eed6 Let plugins use Paseo modals, icons, and toasts (#3967)
* feat(plugins): add React Native UI primitives

Give client plugins host-owned adaptive UI without exposing app internals. Preserve plugin runtime contexts when modal content moves through the compact portal host.

* docs(plugins): clarify host UI reference

* refactor(plugins): harden host UI boundary

Replace mocked pass-through coverage with a real browser plugin journey, keep host runtime ownership inside the evaluator, preserve toast context across compact portals, and expose modal content as a compound component.

* fix(plugins): defer native UI presentation runtime

Loading the plugin evaluator initialized the modal and toast presentation stacks even when no plugin requested host UI. Keep the capability stable while delaying platform-heavy presentation code until Modal.Content renders.

* refactor(plugins): move modal metadata to root

Keep Modal.Content body-only while the top-level Modal owns the host-rendered title and leading icon. This leaves presentation metadata stable as the compound API gains future regions.
2026-08-28 11:43:30 +02:00
Desmond ChaiandMohamed Boudra a1ed5a8bad Stop PR status polling from rate-limiting your entire GitHub account (#3825)
* Batch GitHub PR status polls into aliased GraphQL requests

The PR status poller ran one `gh pr view` (GraphQL-backed) plus a
per-PR facts query for every workspace on its own timer, so cost
scaled linearly with workspace count and exhausted the 5,000 pts/hour
GraphQL quota machine-wide past ~50 workspaces (#3587, #2470).

Replace the per-target timers with a global scheduler that snaps poll
targets to a shared 5s grid and flushes all due targets together:

- One aliased discovery query per batch of up to 25 targets (grouped
  by GH host) resolves PR identity, state, mergeability, and the merge
  facts that previously required a second GraphQL call per PR.
- A second aliased query fetches statusCheckRollup contexts only for
  the PRs that matched. Checks for merged/closed PRs are frozen and
  cached by repo#number@headOid, so idle workspaces settle to the
  discovery query alone.
- A failed batch backs off every target in it with one request spent;
  healthy aliases are recovered from the body gh still prints on
  stdout when a single alias errors (ForgeCommandError now carries
  stdout for this).
- Targets whose origin slug/host is still resolving, and fork repos
  whose PR lives in the parent, fall back to the legacy per-target
  path for that cycle. Unowned lookups filter out other forks' PRs
  that share a branch name.

Fixes #3587. Addresses the API budget in #2470.

* fix(server): reserve the shared GitHub polling budget

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-28 11:29:34 +02:00
Christoph LeiterandClaude Opus 5 3d69b0f458 fix(app): render markdown verbatim instead of substituting characters (#3253)
markdown-it's typographer option rewrites characters after parsing, so
a literal (c) came out as ©. Agents label steps and options (a), (b),
(c) all the time, and an ordinary list rendering as (a), (b), © stops
your eye every time.

The same option turns ... into an ellipsis, --- into an em dash, and
straight quotes into curly ones. Agent output is text people paste
back into a shell or a file, so a curly quote in --name="my repo"
looks correct on screen and fails when pasted. The markdown file
preview had the same problem, showing characters that are not in the
file on disk.

Every in-app markdown surface now goes through one factory in
utils/markdown-parser.ts, which leaves typographer off: agent chat,
plan cards, pull request comment bodies and the file preview. The plan
card was rendering through its library's built-in parser because it
never passed a markdownit prop, and that default has typographer on.

Link behaviour is unchanged. The assistant parser keeps its file://
permission and no other surface gains it, and linkify stays on for
chat and the default renderer and off for plan cards, matching what
each did before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:27 +00:00
Christoph LeiterandClaude Opus 5 51a435d3cf feat(app): find workspaces by PR/MR number in the command center (#3008)
* feat(app): find workspaces by PR/MR number in the command center

The command center matched workspaces on title, host, project, and
branch. Typing the change-request number you actually remember found
nothing, so you had to recall the branch name instead.

Workspace results now carry their pull/merge request number, and a
numeric query resolves it directly: 42, #42, !42, pr 42, and mr 42 all
reach the same workspace. Matching compares the parsed number exactly
rather than as a substring, so 42 no longer drags in PR 142 or a branch
that happens to contain those digits. Exact number hits sort above
ordinary text matches for the same query.

Nothing changes visually. The number is a search key only; result rows
render exactly as before.

The number is read from the pull request the daemon already sends.
selectPrHintFromStatus is the fallback only: it derives the number by
parsing the PR/MR URL, which yields nothing for a noncanonical or
absent URL even when the daemon sent a usable number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): exercise change-request parsing through the matcher

parseChangeRequestQuery was exported only so the test could reach it,
which made an implementation helper part of the module surface and let
the parser cases pass without covering how the parser composes with
workspace matching.

Unexport it and drive the same spellings through matchWorkspaceQuery.
The rejection cases now assert what they actually protect: a query like
fix-42-retries must not take the number path, so a workspace whose PR is
42 does not match it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:50:31 +00:00
Mohamed Boudra fb67d2cdc8 Keep the daemon responsive during Git queue pressure (#3945)
* fix(server): keep Git pressure from stalling daemon

Git process starts were refilled synchronously, so slow process spawning could monopolize the main loop long enough to trip connection liveness checks. Admit one process per event-loop turn and expose refresh/fetch provenance in runtime queue metrics.

* perf(server): avoid delaying isolated Git commands

Start the first command immediately when the scheduler is idle. Backlog admission and completion-driven refills still yield between spawns, preserving daemon liveness without adding a turn to normal sequential Git traffic.

* fix(server): keep Git admission outside fake clocks

Subprocess admission is event-loop ordering rather than simulated wall-clock behavior. Use the native Node immediate so service tests can fake debounce timers without deadlocking queued real Git processes.

* refactor(server): bind Git command provenance explicitly

* test(server): expect bound Git fetch runner
2026-08-28 10:25:20 +02:00
Mohamed Boudra fe803f6e70 Let plugins contribute contextual composer pills (#3956)
* feat(plugin): allow composer pill contributions

Keep pill presentation host-owned while plugin components render contextual content. Pill callbacks reuse the selected host's API, RPC, surface, and registered-panel navigation capabilities without exposing router or workspace layout internals.

* fix(plugin): place composer pills in track bar

* fix(plugin): make composer pills client-owned
2026-08-28 16:02:00 +08:00
a501e1e051 feat(sdk): expose a session's commands and full snapshot on the agent handle (#3719)
* feat(sdk): expose a session's own commands on the agent handle

`DaemonClient.listCommands` has always been able to ask a running session
which slash commands and skills it loaded, but `PaseoAgentHandle` never
surfaced it, so SDK and plugin consumers could only see what they could
find on disk. Provider built-ins — the ones bundled inside the agent
binary rather than installed into a scannable directory — were therefore
invisible to them.

Add `agent.commands()` as a thin delegation to the existing RPC. Every
provider that implements `listCommands` (claude, codex, opencode, acp,
omp, pi) answers it, so a consumer no longer needs per-provider
filesystem knowledge to enumerate what a session can run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sdk): expose the observed agent snapshot on the agent handle

The handle mirrored four snapshot fields (workspaceId, cwd, status, id)
and hid the rest behind current(). Add getters for capabilities,
availableModes, pendingPermissions, activeTurn, lastUsage, lastError,
features, runtimeInfo, and archivedAt so callers can read one field
without destructuring the whole snapshot and null-checking it.

Each reads the last observed snapshot and never fetches, matching the
existing getters. null means "not observed yet".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(sdk): clarify nullable snapshot getters

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-28 07:09:20 +00:00
paseo-ai[bot] 1fc5250544 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-27 18:24:46 +00:00
578 changed files with 31058 additions and 6180 deletions
+45 -27
View File
@@ -20,7 +20,50 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
ensure-release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
--draft
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
fi
publish-android-apk:
needs: [ensure-release]
permissions:
contents: write
packages: read
@@ -36,32 +79,6 @@ jobs:
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -129,4 +146,5 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
+64 -7
View File
@@ -75,7 +75,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
@@ -86,8 +86,10 @@ jobs:
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--notes "" \
--draft \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
}
fi
@@ -191,7 +193,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -299,7 +302,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -404,7 +408,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -424,7 +429,7 @@ jobs:
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -480,7 +485,7 @@ jobs:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
- name: Assemble manifests and publish release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -491,6 +496,33 @@ jobs:
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
expected_manifests=(
"${RELEASE_CHANNEL}-mac.yml"
"${RELEASE_CHANNEL}-linux.yml"
"${RELEASE_CHANNEL}.yml"
)
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
for manifest_name in "${expected_manifests[@]}"; do
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
case "$manifest_name" in
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
esac
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
gh release download "$release_lookup" \
--repo "${{ github.repository }}" \
--pattern "$manifest_name" \
--dir "$manifests_dir"
else
gh release delete-asset "$release_lookup" "$manifest_name" \
--repo "${{ github.repository }}" \
--yes
fi
fi
done
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
@@ -515,6 +547,7 @@ jobs:
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
# shellcheck disable=SC2016
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
@@ -536,4 +569,28 @@ jobs:
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
failed_builds=()
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
if (( ${#failed_builds[@]} > 0 )); then
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
exit 1
fi
missing_manifests=()
for manifest_name in "${expected_manifests[@]}"; do
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
done
if (( ${#missing_manifests[@]} > 0 )); then
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
exit 1
fi
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
if [[ "$IS_PRERELEASE" == "true" ]]; then
publish_args+=(--prerelease)
fi
gh "${publish_args[@]}"
+1 -2
View File
@@ -15,7 +15,7 @@ on:
required: false
type: string
create_if_missing:
description: "Create release if missing (normally only needed for tag events)."
description: "Create a draft release if missing (normally only needed for tag events)."
required: false
default: false
type: boolean
@@ -29,7 +29,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
+64 -1
View File
@@ -1,30 +1,93 @@
# Changelog
## 0.7.0-beta.1 - 2026-08-27
## 0.7.2 - 2026-09-02
### Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
### Improved
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
### Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
## 0.7.1 - 2026-09-01
### Added
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
## 0.7.0 - 2026-08-31
### Changed
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
### Added
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
### Improved
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
### Fixed
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
+1
View File
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
+2 -2
View File
@@ -111,8 +111,8 @@ paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
# run on a remote daemon; --cwd is a path on that host
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
+9
View File
@@ -31,6 +31,15 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
## Measuring
+4 -4
View File
@@ -103,7 +103,7 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
@@ -355,13 +355,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
| State | Key builder / store | Source |
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
+5 -3
View File
@@ -393,6 +393,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"rpcTimeoutMs": 60000,
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
@@ -403,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
@@ -416,7 +417,8 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
"sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000
}
}
}
@@ -424,7 +426,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
+4 -1
View File
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
Weight has three tiers, applied by role:
@@ -120,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
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.
@@ -248,6 +250,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
+5 -2
View File
@@ -556,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host <host:port>` to point the CLI at a different daemon:
Use `--host` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
npm run cli -- ls -a --host localhost:7777
npm run cli -- ls -a --host ssh://user@host
```
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
+12 -21
View File
@@ -132,30 +132,21 @@ quietly relying on:
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
## Gotcha 3 — Keyboard layout and portal anchors
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
Move the chat surface with worklet-driven bottom padding from
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
and native hit testing in the same layout. Do not translate the stream and
composer independently.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
the composer uses**:
Do not use the controller's raw keyboard progress for the padding. It can retain
a nonzero value after the keyboard closes. The shared provider normalizes that
state and reconciles native animation-end events.
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
to the popover wrapper.
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed. Do not call
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
`measureInWindow` already includes the dock's current padding layout. When a
portal opens, snapshot the current shift and apply only the subsequent shift
delta to its animated `bottom`. Adding the full shift moves the portal twice and
can place it over the composer controls.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
+5
View File
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
allowed-failure job carries the `warning` trait, and `canceling` remains active
until GitLab reports a terminal status.
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
targets per host, batch their reads, and stop until GitHub's reset time when
the reserve is exhausted. Never add a per-target GitHub request to the poll
path. Resolve fork PRs through their parent repository without abandoning the
shared batch.
+1
View File
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+12 -8
View File
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
## Connection and authority
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.
## Session grants and execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
shape.
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
+21 -10
View File
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
- the active gesture's starting revision
- the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
receive pointer events. Worklets never own `display` or `pointerEvents`.
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
React owns pointer events and accessibility.
## Why one position
@@ -55,8 +57,10 @@ while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
motion immediately; the matching React command adopts that motion instead of restarting it. Position
settlement and command acceptance may arrive in either order. Activity changes after both agree on
the same target.
Manual gesture arbitration has two phases:
@@ -85,8 +89,14 @@ definition, no longer eligible to begin.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
prop commit from resurrecting a closed overlay.
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
animated props back to React through a collector that forgets entries older than two seconds. A JS
stall across a panel settle loses the final position, and the next React commit reverts the overlay
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
the collector in 4.4.3, which needs React Native 0.83.
- Gesture start and progress must not update React state. The retained overlay is already mounted and
offscreen; only the shared position and its derived native styles move during a drag.
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
pointer events, and the active signal consumed by `useRetainedPanelActive`.
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
@@ -98,8 +108,9 @@ definition, no longer eligible to begin.
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- The newly active panel must be included in the same render that changes selection. Adding it from an
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
canonical target's anchor is the settlement signal; animation duration and completion callbacks
do not own activity. Cancellation publishes nothing.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
@@ -107,5 +118,5 @@ definition, no longer eligible to begin.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
a sequence there whenever ownership or ordering changes.
+6 -4
View File
@@ -259,7 +259,9 @@ background music stays paused. On iOS this is not just a "while recording" probl
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
it every time the app returns to the foreground — so one dictation turn kills music for the life of
the process, on every open, until the app is force-quit. Android holds
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
@@ -267,9 +269,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
wrappers (voice provider + dictation) and only it knows the true state.
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
playing.
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
background/foreground the app and confirm it keeps playing at full quality.
## Unistyles + Reanimated
+59
View File
@@ -0,0 +1,59 @@
# Daemon permissions
The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority.
## Model
```text
principal -> grants
|
+-- authenticated by a device or service credential
`-- opens a session with equal or narrower authority
```
A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts.
A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer.
## Permissions
| Permission | Authority |
| ------------------- | -------------------------------------------------------------------------- |
| `daemon.read` | Daemon status, diagnostics, configuration, and provider information |
| `daemon.manage` | Restart, update, configuration changes, providers, skills, and plugins |
| `tunnel.manage` | Relay, Hub, service tunnel, and public endpoint relationships |
| `access.manage` | Pairing invitations, principals, credentials, grants, and revocation |
| `workspace.read` | Projects, workspaces, agents, timelines, files, diffs, and terminal output |
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | The Hub-owned execution lifecycle |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
## Resources
Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module:
```ts
type Grant = {
permission: Permission;
resource: { kind: "daemon" } | { kind: "workspace"; ids: string[] };
};
```
A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them.
Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships.
## Hub
The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access.
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
+155 -38
View File
@@ -1,9 +1,11 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
Paseo does not sandbox it.
client slash commands, timeline items, composer pills, app themes, and composer attachment sources
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
## Install a directory source
@@ -47,15 +49,13 @@ my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
SDK contract changes.
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
runtime modules, so consumers do not install these packages when adding the plugin.
```json
{
@@ -84,28 +84,52 @@ directory always wins over shorthand resolution.
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo --path plugins/review
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin status
paseo plugin update review
paseo plugin update --all
```
Append `:relative/path` to the source when the plugin lives below the repository root.
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
it before changing the configured path. A compilation or startup failure restores the running
version. Removing a Git source deletes Paseo's managed checkout.
commits. Removing a Git source deletes Paseo's managed checkout.
Git installation does not run a package manager or install script. A distributable source plugin
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
dependencies work only when the Paseo app already ships the native module.
### Declare Git preparation
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
that Paseo does not provide, generate source or assets, or perform another required preparation
step:
```json
{
"id": "review",
"build": [
["npm", "ci"],
["npm", "run", "build"]
]
}
```
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
of this happens on the remote daemon host.
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
log credentials or tokens.
@@ -121,17 +145,20 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`.
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
@@ -183,13 +210,13 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
Workspace panels and Command Center items remain client contributions. The daemon transports their
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
app state or a memoized selection. Panels use one persisted
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
@@ -198,27 +225,117 @@ when moved between hosts. Explorer configuration can create workspace-context pa
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
workspace-layout store access.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
Plugins do not receive Expo Router or workspace-layout store access.
## Contribute composer pills
Register a headless client entrypoint, then add and remove targeted pills from that client
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
client state without mounting a panel or surface.
```tsx
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
```
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
the host, or unloading the app tears down the contribution.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
built-in projection stay unchanged. The app transforms fetched projected history before building
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
collapsed before the transformer replaces anything.
built-in projection stay unchanged. The app transforms each source item while building the render
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
streaming updates preserve mounted component identity.
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
schema, and mounts the component inside the normal plugin runtime and error boundary.
runtime plugin ID, preserves the source timeline cursor and identity, validates renderer data with
its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An
optional output `id` distinguishes several stable replacements from the same source item; its output
index is the default.
Transformers run synchronously and must be deterministic. When several transformers match, the
first one that returns a result owns that source item. Plugin and registration ordering is stable.
See `plugin-examples/timeline-items` for the complete contract.
A plugin subprocess can also append a canonical plugin row from a server handler:
```ts
await paseo.agents.ref(agentId).timeline.append({
type: "plugin",
id: "review",
kind: "review-result",
version: 1,
data: { status: "ready" },
});
```
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. The row stays
in canonical history and renders an unavailable placeholder when its renderer is not installed.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
plugin.addClientSlashCommand({
name: "review",
description: "Run the review bot",
argumentHint: "[scope]",
context: "agent",
async onSubmit({ args, agent, rpc }) {
await rpc(startReview, { agentId: agent.id, scope: args });
},
});
```
Paseo owns the autocomplete row, input clearing, and error toast. It never sends the command text to
the agent. Built-in client commands win name and alias collisions, plugin commands win
provider-command collisions, and the first plugin in stable catalog order wins collisions between
plugins. Plugin slash commands do not run when the composer has attachments.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
+7 -3
View File
@@ -67,6 +67,8 @@ Pi model records expose input capabilities through `model.input`. Only send raw
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
@@ -75,15 +77,17 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
+30 -7
View File
@@ -112,7 +112,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -180,10 +180,10 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
### Default behavior
@@ -375,7 +375,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
## Fixing a failed release build
@@ -404,6 +404,13 @@ you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
uploads manifests from successful platforms before it fails. A later
single-platform retry reuses those manifests, stamps the complete set with one
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
the same path against the existing draft.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
@@ -433,6 +440,22 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
If you decide to publish a release without working desktop builds, inspect its
assets first, then publish it manually:
```bash
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
# Keep a beta marked as a prerelease:
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
```
This bypasses the updater-manifest guarantee. Use it only when the release is
intentionally unavailable to desktop updater clients.
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
@@ -585,7 +608,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
@@ -608,7 +631,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE=
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -41,6 +41,7 @@
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -23,7 +23,7 @@ async function completeSubmittedTurn(
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
@@ -40,7 +40,7 @@ async function expectTurnCompletesNormally(
): Promise<void> {
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expectAgentIdle(page);
}
@@ -39,7 +39,10 @@ import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import {
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
} from "../support/helpers/workspace-ui";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
@@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
@@ -14,6 +14,7 @@ import {
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
@@ -45,6 +46,56 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("opens a timeline image in a zoomable lightbox", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Zoomable timeline image",
fileName: "zoomable-timeline-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Zoomable image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.getByRole("button", { name: "Open image attachment" }).click();
const lightboxImage = page.getByTestId("attachment-lightbox-image");
const canvas = page.getByTestId("attachment-lightbox-canvas");
await expect(lightboxImage).toBeVisible();
await canvas.hover();
const transformedContent = canvas.locator(":scope > div").first();
const initialBox = await transformedContent.boundingBox();
const canvasBox = await canvas.boundingBox();
expect(initialBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
expect(initialBox!.width).toBeLessThan(canvasBox!.width);
const zoomIn = page.getByRole("button", { name: "Zoom in", exact: true });
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
await expect
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
.toBeGreaterThan(canvasBox!.width);
await canvas.dblclick({ position: { x: canvasBox!.width / 2, y: canvasBox!.height / 2 } });
await expect
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
.toBe(Math.round(initialBox!.width));
await workspace.client.sendAgentMessage(
imageAgent.id,
"Stay running while the assistant image lightbox is closed.",
);
await expectAgentReadyToInterrupt(page);
await page.keyboard.press("Escape");
await expect(lightboxImage).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
imageWorkspace: workspace,
page,
@@ -10,6 +10,7 @@ import {
archiveAgentFromSessions,
clickSessionRow,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
@@ -21,6 +22,7 @@ import {
resetSeededPageState,
reloadWorkspace,
} from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
@@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => {
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
await expectAgentTabActive(page, archived.id);
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-message-render-limit-",
title: "Assistant message render limit",
initialPrompt: "Render the configured oversized response.",
featureValues: { mockAssistantResponse: response },
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").last();
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
await expect(assistantMessage).not.toContainText(hiddenTail);
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
await expect(notice).toBeVisible();
await expect(notice).toHaveText(
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
);
await expect(notice).toHaveCSS("font-style", "italic");
} finally {
await agent.cleanup();
}
});
+351 -64
View File
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
return page.getByTestId("changes-file-tree").filter({ visible: true });
}
function diffHeaderForPath(panel: Locator, filePath: string): Locator {
return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/);
}
async function readFileIfPresent(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, "utf8");
@@ -261,12 +265,12 @@ test.afterEach(async () => {
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
@@ -302,17 +306,33 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
page,
}) => {
const workspace = await createWorkspaceWithScrollableComparisons();
await openWorkspaceChangesSurface(page, workspace);
await selectChangesComparison(page, "Committed");
await expectWorkingComparisonFiles(page, "committed");
await selectChangedFileAndExpectFocused(page, "committed/50-target.ts");
await selectChangesComparison(page, "Uncommitted");
await expectWorkingComparisonFiles(page, "uncommitted");
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
});
test("changes file actions open below the right-click without a reserved kebab", async ({
@@ -322,9 +342,8 @@ test("changes file actions open below the right-click without a reserved kebab",
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none");
const deletedFileName = page.getByTestId("diff-file-1");
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
@@ -378,49 +397,109 @@ test("every interactive file header has the same hover feedback", async ({ page
const first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle");
const normalBackground = await first.evaluate(
(element) => getComputedStyle(element.parentElement!).backgroundColor,
);
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
)
.toBe("0px");
const canvas = page.getByTestId("git-diff-header-canvas");
const normalBackground = await headerCanvasPixel(canvas, first, 10);
await first.hover();
const hoverBackground = await first.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(hoverBackground).not.toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
await first.click();
await page.mouse.move(0, 0);
await expect(first).toHaveAttribute("aria-expanded", "false");
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
)
.toBe(normalBackground);
const [sharedBorder, secondTopBorderWidth] = await Promise.all([
first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor),
second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
]);
expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)");
expect(secondTopBorderWidth).toBe("0px");
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await first.hover();
await expect
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
await second.hover();
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
});
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
const headerCanvas = page.getByTestId("git-diff-header-canvas");
const header = page.getByTestId("diff-file-0-toggle");
const beforeImage = await headerCanvas.evaluate((element) =>
(element as HTMLCanvasElement).toDataURL(),
);
const beforeBounds = await header.boundingBox();
await horizontallyScrollFirstFile(page, 320);
await page.waitForTimeout(50);
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
beforeImage,
);
expect(await header.boundingBox()).toEqual(beforeBounds);
});
test("the outgoing sticky header hands off without a gap or overlap", async ({
page,
}, testInfo) => {
const workspace = await createWorkspaceWithStickyTransitionDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace);
const scroller = page.getByTestId("git-diff-scroll");
const first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle");
const secondTop = await second.evaluate((element) => {
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
if (!scrollElement) return Number.NaN;
return (
scrollElement.scrollTop +
element.getBoundingClientRect().top -
scrollElement.getBoundingClientRect().top
);
});
expect(secondTop).toBeGreaterThan(30);
await scroller.evaluate((element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, secondTop - 15);
await expect(first).toBeAttached();
await expect(second).toBeAttached();
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
scroller.boundingBox(),
first.boundingBox(),
second.boundingBox(),
]);
if (!scrollBounds || !firstBounds || !secondBounds) {
throw new Error("Sticky transition geometry is unavailable");
}
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
const transitionHits = await scroller.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return [14, 16].map(
(y) =>
document
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
);
});
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
await testInfo.attach("canvas-header-sticky-handoff", {
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
contentType: "image/png",
});
await scroller.evaluate((element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, secondTop);
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
)
.toBe(normalBackground);
await expect
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
.poll(async () => {
const bounds = await second.boundingBox();
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
})
.toBeCloseTo(0, 0);
});
test("changes context menus duplicate files and folders", async ({ page }) => {
@@ -475,7 +554,8 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
const tree = changesTree(page);
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
// Directories sort before files at every level, so the root note lands last
// even though "a-root-note.txt" compares below "src/..." as a whole string.
@@ -485,9 +565,17 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
// descendants of a collapsed folder, which would make the tree a subsequence
// of the diff rather than a match, and mask a real ordering difference.
await expect(treeNames).toHaveCount(expected.length);
await expect(diffNames).toHaveCount(expected.length);
await expect(diffHeaders).toHaveCount(expected.length);
await expect(treeNames).toHaveText(expected);
await expect(diffNames).toHaveText(expected);
const expectedHeaderNames = [
"src/zz-folder/nested/changed.ts, +1, -1",
"src/zz-folder/root.ts, +1, -1",
"src/use-mounted-tab-set.ts, +37, -1",
"a-root-note.txt, +1, -0",
];
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
}
});
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
@@ -586,9 +674,10 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const renamedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-renamed.ts" });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
@@ -615,9 +704,10 @@ test("discarding an untracked file removes it from the working tree", async ({ p
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const untrackedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-untracked.txt" });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
@@ -676,8 +766,12 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
"src/use-mounted-tab-set.ts, +37, -1",
);
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
"src/zz-deleted.ts, +0, -1",
);
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
@@ -712,16 +806,18 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
"src/use-mounted-tab-set.ts, +37, -1",
{ timeout: 30_000 },
);
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
"src/zz-deleted.ts, +0, -1",
);
});
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
@@ -980,6 +1076,29 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
});
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
page,
}) => {
test.setTimeout(180_000);
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace, 90_000);
const root = page.getByTestId("git-diff-canvas-root");
const scroller = page.getByTestId("git-diff-scroll");
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
});
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
page,
}) => {
@@ -1363,6 +1482,46 @@ async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithScrollableComparisons(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-comparison-focus-", {
files: [{ path: "tracked.ts", content: "export const tracked = true;\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const longFile = Array.from(
{ length: 120 },
(_, index) => `export const line${index} = ${index};`,
).join("\n");
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeComparisonFiles(repo.path, "committed", longFile);
execFileSync("git", ["add", "committed"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Add committed comparison files"], { cwd: repo.path });
await writeComparisonFiles(repo.path, "uncommitted", longFile);
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create comparison workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function writeComparisonFiles(
repoPath: string,
directory: "committed" | "uncommitted",
longFile: string,
): Promise<void> {
await mkdir(path.join(repoPath, directory), { recursive: true });
await Promise.all([
writeFile(path.join(repoPath, directory, "00-before.ts"), `${longFile}\n`),
writeFile(path.join(repoPath, directory, "50-target.ts"), "export const target = true;\n"),
writeFile(path.join(repoPath, directory, "99-after.ts"), `${longFile}\n`),
]);
}
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-", {
files: [{ path: "src/selection.ts", content: "" }],
@@ -1380,6 +1539,58 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
content: "export const value = 0;\n",
}));
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
await Promise.all(
files.map((file, index) =>
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
),
);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace> {
const source = Array.from(
{ length: 48 },
(_, index) => `export const value${index} = ${index};`,
).join("\n");
const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({
path: filePath,
content: `${source}\n`,
}));
const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files });
const changedSource = Array.from(
{ length: 48 },
(_, index) => `export const value${index} = ${index + 100};`,
).join("\n");
await Promise.all(
files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)),
);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
@@ -1389,11 +1600,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page);
}
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
async function openWorkspaceChangesSurface(
page: Page,
workspace: DirtyWorkspace,
timeout?: number,
): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await openChangesPanel(page, timeout);
}
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -1402,7 +1617,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page);
@@ -1424,6 +1639,54 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
});
}
async function selectChangesComparison(
page: Page,
comparison: "Committed" | "Uncommitted",
): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await tree.getByTestId("changes-diff-status-trigger").click();
await page.getByTestId(`changes-diff-mode-${comparison.toLowerCase()}`).click();
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText(comparison);
}
async function expectWorkingComparisonFiles(
page: Page,
comparison: "committed" | "uncommitted",
): Promise<void> {
const tree = changesTree(page);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const paths = ["00-before.ts", "50-target.ts", "99-after.ts"];
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
paths,
);
for (const fileName of paths) {
await expect(diffHeaderForPath(panel, `${comparison}/${fileName}`)).toBeAttached();
}
}
async function selectChangedFileAndExpectFocused(page: Page, filePath: string): Promise<void> {
const tree = changesTree(page);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const scroller = panel.getByTestId("git-diff-scroll");
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await tree.getByText(path.basename(filePath), { exact: true }).click();
const header = diffHeaderForPath(panel, filePath);
await expect(header).toBeVisible();
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
const [scrollBounds, headerBounds] = await Promise.all([
scroller.boundingBox(),
header.boundingBox(),
]);
if (!scrollBounds || !headerBounds)
throw new Error("Focused diff header geometry is unavailable");
expect(headerBounds.y - scrollBounds.y).toBeCloseTo(0, 0);
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
@@ -1701,6 +1964,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
await page.mouse.up();
}
async function headerCanvasPixel(
canvas: Locator,
header: Locator,
offsetY: number,
): Promise<string> {
const [canvasBounds, headerBounds] = await Promise.all([
canvas.boundingBox(),
header.boundingBox(),
]);
if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds");
return canvas.evaluate(
(element, point) => {
const target = element as HTMLCanvasElement;
const ratio = target.width / target.getBoundingClientRect().width;
const context = target.getContext("2d");
if (!context) throw new Error("Canvas header has no 2D context");
return Array.from(
context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data,
).join(",");
},
{ x: 4, y: headerBounds.y - canvasBounds.y + offsetY },
);
}
async function readDiffTypographyGeometry(page: Page): Promise<{
horizontalExtent: number;
canvasPixels: string;
@@ -0,0 +1,270 @@
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
function workspaceRow(page: Page, workspaceId: string): Locator {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function action(panel: Locator, title: string): Locator {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
}
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
const row = workspaceRow(page, workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
// that used to make the sidebar-owned rename dialog unreachable.
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: project.projectDisplayName });
await expect(header).toHaveCount(1, { timeout: 30_000 });
await header.click();
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
// surface under test anyway, and it works from every route.
async function openCommandCenter(page: Page): Promise<Locator> {
await page.keyboard.press("ControlOrMeta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
async function runCommand(page: Page, query: string, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
const entry = action(panel, title);
await expect(entry).toBeVisible({ timeout: 15_000 });
await entry.click();
}
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
const workspaces = await workspace.client.fetchWorkspaces();
for (const entry of workspaces.entries) {
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
}
return undefined;
}
async function rejectNextLabelAssignment(page: Page): Promise<void> {
let rejectNext = true;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
let request: { type?: string; requestId?: string } | null = null;
try {
const envelope = JSON.parse(raw) as {
type?: string;
message?: { type?: string; requestId?: string };
};
request = envelope.type === "session" ? (envelope.message ?? null) : null;
} catch {
server.send(message);
return;
}
if (
rejectNext &&
request?.type === "workspace.label.assignment.set.request" &&
request.requestId
) {
rejectNext = false;
browser.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: "Injected label mutation failure.",
code: "handler_error",
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
}
test.describe("Command center workspace management", () => {
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await collapseProjectSection(page, workspace);
await runCommand(page, "rename", "Rename workspace");
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("copies the workspace path and confirms with a toast", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "copy path", "Copy workspace path");
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
// off a workspace route would give the user two entries that close the palette and do nothing.
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("toggles a label on the active workspace from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
try {
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
// Unassigning the only label drops the field entirely rather than leaving an empty array.
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
const reopened = await openCommandCenter(page);
await reopened.getByTestId("command-center-input").fill("urgent");
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
test("shows an error toast when assigning a label fails", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
try {
await rejectNextLabelAssignment(page);
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
await expect(page.getByTestId("app-toast-message")).toContainText(
"Injected label mutation failure.",
);
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
});
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
}
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
const [header, content, name, stat] = await Promise.all([
const [header, canvas] = await Promise.all([
panel.getByTestId("diff-file-0").boundingBox(),
panel.getByTestId("diff-file-0-header-content").boundingBox(),
panel.getByTestId("diff-file-0-name").boundingBox(),
panel.getByTestId("diff-file-0-stat").boundingBox(),
panel.getByTestId("git-diff-header-canvas").boundingBox(),
]);
expect(header).not.toBeNull();
expect(content).not.toBeNull();
expect(name).not.toBeNull();
expect(stat).not.toBeNull();
expect(canvas).not.toBeNull();
expect(header!.height).toBeCloseTo(30, 0);
expect(content!.x).toBeCloseTo(header!.x, 0);
expect(content!.width).toBeCloseTo(header!.width, 0);
expect(name!.x - header!.x).toBeCloseTo(12, 0);
expect(name!.x + name!.width).toBeLessThan(stat!.x);
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
1,
);
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
expect(header!.x).toBeCloseTo(canvas!.x, 0);
expect(header!.width).toBeCloseTo(canvas!.width, 0);
expect(header!.y).toBeCloseTo(canvas!.y, 0);
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
}
@@ -0,0 +1,29 @@
import { test } from "../support/fixtures";
import {
expectComposerDraft,
expectComposerFocused,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-",
title: "Composer focus",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
} finally {
await agent.cleanup();
}
});
+266 -23
View File
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const RUN_HEADER_COMPARISON = process.env.PASEO_DIFF_HEADER_COMPARISON === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT_PER_FILE = 1_200;
const MANY_FILE_COUNT = Number(process.env.PASEO_DIFF_MANY_FILE_COUNT ?? 2_000);
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
const POC_P95_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
);
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
const MANY_FILE_MAX_P95_FRAME_MS = 80;
diffPerfDescribe("Diff canvas performance", () => {
test("opening a review stays responsive in a large diff", async ({ page }) => {
@@ -58,7 +62,131 @@ diffPerfDescribe("Diff canvas performance", () => {
}
});
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
test("many small files keep canvas headers bounded and responsive", async ({
page,
}, testInfo) => {
test.setTimeout(300_000);
const repo = await createManyFileDiffRepository(MANY_FILE_COUNT);
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create many-file performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await page.setViewportSize({ width: 1400, height: 900 });
const openedAt = performance.now();
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("diff-file-0-toggle")).toBeVisible({ timeout: 30_000 });
const readyMs = performance.now() - openedAt;
const [initialShells, initialElements, paintKind] = await page
.getByTestId("git-diff-canvas-root")
.evaluate(
(root) =>
[
root.querySelectorAll('[data-diff-header="true"]').length,
root.querySelectorAll("*").length,
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
] as const,
);
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
const style = getComputedStyle(header);
const bounds = header.getBoundingClientRect();
const leaves = [];
for (const element of header.querySelectorAll<HTMLElement>("*")) {
if (element.children.length !== 0 || !element.textContent?.trim()) continue;
const leafStyle = getComputedStyle(element);
const leafBounds = element.getBoundingClientRect();
leaves.push({
text: element.textContent,
x: leafBounds.x - bounds.x,
y: leafBounds.y - bounds.y,
width: leafBounds.width,
height: leafBounds.height,
font: leafStyle.font,
lineHeight: leafStyle.lineHeight,
color: leafStyle.color,
});
}
return {
width: bounds.width,
height: bounds.height,
padding: style.padding,
border: style.border,
boxShadow: style.boxShadow,
leaves,
};
});
const headerCanvasMetrics = await page
.getByTestId("git-diff-header-canvas")
.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Header canvas has no 2D context");
const font = context.font;
return {
font,
nameWidth: context.measureText("file-0000.ts").width,
directoryWidth: context.measureText(" src").width,
};
})
.catch(() => null);
const capturePath = process.env.PASEO_DIFF_HEADER_CAPTURE_PATH;
if (capturePath) {
await writeFile(capturePath, await page.getByTestId("git-diff-canvas-root").screenshot());
}
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startRendererInstrumentation(page);
const benchmark = await benchmarkManyFileHeaderScrolling(page);
const renderer = await readRendererInstrumentation(page);
const heap = await collectHeapUsage(cdp);
await attachPerformanceReport(testInfo, {
scenario: "many-small-files",
sourceFiles: MANY_FILE_COUNT,
cpuSlowdown: CPU_SLOWDOWN,
readyMs,
initialShells,
initialElements,
paintKind,
headerDomMetrics,
headerCanvasMetrics,
benchmark,
renderer,
heap,
});
console.log(
`[perf] Many-file headers: ${JSON.stringify({ readyMs, initialShells, initialElements, paintKind, headerDomMetrics, headerCanvasMetrics, benchmark, renderer, heap })}`,
);
if (!RUN_HEADER_COMPARISON) {
expect(benchmark.maximumShells).toBeLessThan(120);
expect(benchmark.blankHeaderFrames).toBe(0);
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
expect(Math.abs(benchmark.maximumScrollTop - benchmark.endScrollTop)).toBeLessThanOrEqual(
1,
);
expect(renderer.reactCommits).toBeLessThanOrEqual(80);
}
} finally {
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("bounded canvases stay filled through continuous fast scrolling", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const repo = await createLargeDiffRepository();
const client = await connectSeedClient();
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
await expectBoundedCanvasSurfaces(page);
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startChromiumTrace(cdp);
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
const renderer = await readRendererInstrumentation(page);
const measuredCycles = scrollReports.slice(1);
console.log(
`[perf] Large-file canvas: ${JSON.stringify({ scrollReports, heapUsage, renderer })}`,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
testInfo: TestInfo,
name: "wrapped" | "split",
) {
await expectSingleCanvasSurface(page);
await expectBoundedCanvasSurfaces(page);
await startRendererInstrumentation(page);
const scrollReports = [];
const heapUsage = [];
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
const renderer = await readRendererInstrumentation(page);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
return {
scrollReports,
heapUsage,
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
return repo;
}
async function createManyFileDiffRepository(fileCount: number) {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
content: "export const value = 0;\n",
}));
const repo = await createTempGitRepo("diff-perf-many-files-", { files });
await Promise.all(
files.map((file, index) =>
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
),
);
return repo;
}
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
return performance.now() - startedAt;
}
async function expectSingleCanvasSurface(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(1);
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(2);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page
.getByTestId("git-diff-canvas-root")
@@ -377,6 +522,92 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
});
}
async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
medianFrameMs: number;
p95FrameMs: number;
maximumFrameMs: number;
maximumShells: number;
blankHeaderFrames: number;
endScrollTop: number;
maximumScrollTop: number;
}> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="git-diff-header-canvas"]',
);
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
const frameDurations: number[] = [];
let maximumShells = 0;
let blankHeaderFrames = 0;
const sampleHeader = () => {
maximumShells = Math.max(
maximumShells,
document.querySelectorAll('[data-diff-header="true"]').length,
);
if (canvas) {
const context = canvas.getContext("2d")!;
const ratio = canvas.width / canvas.getBoundingClientRect().width;
if (
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
) {
blankHeaderFrames += 1;
}
return;
}
const bounds = scroll.getBoundingClientRect();
if (
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
) {
blankHeaderFrames += 1;
}
};
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
const sweepStart = Math.min(
maximumScrollTop,
Math.max(0, maximumScrollTop * progress - scroll.clientHeight),
);
scroll.scrollTop = sweepStart;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
let previousFrame = performance.now();
for (let step = 1; step <= 24; step += 1) {
scroll.scrollTop = Math.min(
maximumScrollTop,
sweepStart + (scroll.clientHeight * step) / 24,
);
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const now = performance.now();
frameDurations.push(now - previousFrame);
previousFrame = now;
sampleHeader();
}
}
scroll.scrollTop = maximumScrollTop;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sampleHeader();
const ordered = [...frameDurations].sort((left, right) => left - right);
const percentile = (fraction: number) =>
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
return {
medianFrameMs: percentile(0.5),
p95FrameMs: percentile(0.95),
maximumFrameMs: Math.max(...frameDurations),
maximumShells,
blankHeaderFrames,
endScrollTop: scroll.scrollTop,
maximumScrollTop,
};
});
}
interface CapturedCanvasFrame {
scrollTop: number;
image: Buffer;
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
page: Page,
): Promise<void> {
const scroller = page.getByTestId("git-diff-scroll");
const first = page.getByTestId("diff-file-0-horizontal-scroll");
const second = page.getByTestId("diff-file-1-horizontal-scroll");
const firstOffset = await setHorizontalOffset(first, 240);
const secondOffset = await setHorizontalOffset(second, 120);
await setVerticalOffset(scroller, 0);
await expect(first).toBeAttached();
const firstOffset = await setHorizontalOffset(first, 40);
await setVerticalOffset(scroller, "end");
await expect(second).toBeAttached();
const secondOffset = await setHorizontalOffset(second, 20);
expect(firstOffset).not.toBe(secondOffset);
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await setVerticalOffset(scroller, 0);
await expect(first).toBeAttached();
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
await setVerticalOffset(scroller, "end");
await expect(second).toBeAttached();
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
}
async function setVerticalOffset(locator: Locator, offset: number | "end") {
await locator.evaluate(async (element, nextOffset) => {
element.scrollTop =
nextOffset === "end" ? element.scrollHeight - element.clientHeight : nextOffset;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}, offset);
}
async function setHorizontalOffset(locator: Locator, offset: number) {
return locator.evaluate(async (element, nextOffset) => {
element.scrollLeft = nextOffset;
@@ -538,6 +538,16 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
await expect(image).toBeVisible();
const imageCanvas = page.getByTestId("image-file-preview-canvas");
await expect(imageCanvas).toBeVisible();
await imageCanvas.hover();
const transformedContent = imageCanvas.locator(":scope > div").first();
const fittedImageBox = await transformedContent.boundingBox();
expect(fittedImageBox).not.toBeNull();
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
.toBeGreaterThan(fittedImageBox!.width * 1.2);
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
@@ -35,6 +35,7 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
page,
}) => {
test.setTimeout(120_000);
let diagramTurnCompleted!: Promise<void>;
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
@@ -48,15 +49,16 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
await test.step("Open the conversation and request a diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
});
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
await expectDiagramWithLabels(page, ["Start", "Middle"]);
await expectDiagramRemainsRenderedWhileStreaming(page);
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
});
await test.step("The completed diagram shows the final streamed content", async () => {
await waitForDiagramTurnToComplete(agent);
await diagramTurnCompleted;
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
@@ -27,7 +27,10 @@ test("chooses a metadata model and can return to automatic selection", async ({
}, testInfo) => {
await openMetadataGenerationSettings(page);
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
"About Metadata generation",
);
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
@@ -0,0 +1,124 @@
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "../support/helpers/rewind-flow";
const GREETING = "Say hello back.";
test.use({ e2eInjectPaseoTools: true });
function verificationPrompt(workspacePath: string): string {
return [
"Use Paseo tools to perform this exact UI verification.",
"Do not edit files or run shell commands. Every prompt below is intentionally non-actionable.",
`1. Create a local workspace at ${workspacePath} titled "Presentation QA workspace".`,
`2. In that workspace, create a codex/gpt-5.4-mini agent titled "Greeting child" with the initial prompt "${GREETING}".`,
`3. Create a schedule named "Greeting schedule" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", provider "codex/gpt-5.4-mini", cwd ${workspacePath}, maxRuns 1, and expiresIn "1h". Delete that schedule using its returned id.`,
`4. Create a heartbeat named "Greeting heartbeat" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", maxRuns 1, and expiresIn "1h". Delete that heartbeat using its returned id.`,
`5. After the child agent finishes, send it the follow-up prompt "${GREETING}" synchronously.`,
"6. Archive the workspace from step 1.",
"7. Reply with exactly TOOL_CALL_QA_DONE.",
"Use each named Paseo tool exactly once except for the matching schedule and heartbeat deletion tools.",
].join(" ");
}
async function expandedToolCall(page: Page, label: string): Promise<Locator> {
const badge = page.getByTestId("tool-call-badge").filter({ hasText: label }).last();
await expect(badge).toBeVisible({ timeout: 30_000 });
await badge.scrollIntoViewIfNeeded();
await badge.click();
return badge;
}
async function captureToolCall(
page: Page,
testInfo: TestInfo,
input: { label: string; artifact: string; expectedText: Array<string | RegExp> },
): Promise<void> {
const badge = await expandedToolCall(page, input.label);
for (const text of input.expectedText) {
await expect(badge).toContainText(text);
}
const screenshot = testInfo.outputPath(`${input.artifact}.png`);
await badge.screenshot({ path: screenshot });
await testInfo.attach(input.artifact, { path: screenshot, contentType: "image/png" });
await badge.click();
}
async function runPresentationJourney(handle: AgentHandle, workspacePath: string): Promise<void> {
await sendMessage(handle, verificationPrompt(workspacePath));
await expect(
handle.page.getByTestId("assistant-message").filter({ hasText: "TOOL_CALL_QA_DONE" }).last(),
).toBeVisible({ timeout: 30_000 });
}
test.describe("real Codex Paseo tool-call presentation", () => {
test.setTimeout(600_000);
test("renders owned tool inputs and results as readable text", async ({ page }, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-tool-call-presentation-")));
const childWorkspacePath = path.join(cwd, "presentation-workspace");
mkdirSync(childWorkspacePath);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await runPresentationJourney(handle, childWorkspacePath);
await page.setViewportSize({ width: 1280, height: 1600 });
await captureToolCall(page, testInfo, {
label: "Create workspace",
artifact: "create-workspace-presentation",
expectedText: ["Details", "Presentation QA workspace", childWorkspacePath, "local"],
});
await captureToolCall(page, testInfo, {
label: "Create agent",
artifact: "create-agent-prompt-presentation",
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Send agent prompt",
artifact: "send-prompt-presentation",
expectedText: ["Prompt", GREETING, "Agent", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Create schedule",
artifact: "schedule-presentation",
expectedText: [
"Prompt",
GREETING,
"0 0 1 1 *",
"Europe/Berlin",
"Greeting schedule",
"Result",
],
});
await captureToolCall(page, testInfo, {
label: "Create heartbeat",
artifact: "heartbeat-presentation",
expectedText: [
"Prompt",
GREETING,
"0 0 1 1 *",
"Europe/Berlin",
"Greeting heartbeat",
"Result",
],
});
await captureToolCall(page, testInfo, {
label: "Archive workspace",
artifact: "archive-workspace-presentation",
expectedText: ["Details", "Workspace", "Result", "Removed directory"],
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,37 @@
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The plan card has to pass its own markdown parser. Drop that prop and
// react-native-markdown-display silently supplies one with `typographer: true`
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
// No unit test can catch that, because the defect lives in the JSX.
//
// Nothing here is provider-specific: the mock provider drives it, and the
// behavior is the same for every agent.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "plan-card-markdown-",
title: "Plan card markdown e2e",
initialPrompt: "Emit synthetic plan approval.",
});
try {
await openAgentRoute(page, session);
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
await expect(planCard).not.toContainText("©");
await expect(planCard).not.toContainText("—buzz");
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,151 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function ModalBody({ onSaved }) {
usePaseo();
useQueryClient();
const toast = useToast();
function save() {
toast.show("Issue saved", { variant: "success" });
onSaved();
}
return <View>
<Text>Plugin modal contexts ready</Text>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
</View>;
}
function Surface() {
const [open, setOpen] = useState(false);
return <View>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row" }}>
<Icon name="Pencil" size={18} />
<Text>Open plugin modal</Text>
</View>
</Pressable>
<Modal
title="Edit plugin issue"
icon={<Icon name="Pencil" size={18} />}
open={open}
onOpenChange={setOpen}
>
<Modal.Content>
<ModalBody onSaved={() => setOpen(false)} />
</Modal.Content>
</Modal>
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({
id: "main",
title: "Host UI",
icon: "PanelsTopLeft",
surface: "main",
});
return () => {};
}`;
async function openHostUiPlugin(page: Page): Promise<void> {
await gotoAppShell(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function useNonCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 1100, height: 800 });
}
async function useCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(dialog).not.toBeVisible();
}
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function savePluginIssue(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save issue", exact: true }).click();
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
}
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await useNonCompactLayout(page);
await openHostUiPlugin(page);
await test.step("non-compact layouts use a centered dialog", async () => {
await openPluginModal(page);
await expectCenteredPluginDialog(page);
await closeCenteredPluginDialog(page);
});
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
await useCompactLayout(page);
await reopenHostUiPluginFromCompactSidebar(page);
await openPluginModal(page);
await expectCompactPluginSheet(page);
await savePluginIssue(page);
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -5,16 +5,14 @@ import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { submitMessage } from "../support/helpers/composer";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
} from "../support/helpers/sidebar";
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
@@ -28,10 +26,18 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginSource(): string {
function pluginSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin";
import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -46,15 +52,70 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function DirectCollisionSurface() {
return <View><Text>Direct collision surface</Text></View>;
function DirectCollisionSurface({ navigation }) {
return <View>
<Text>Direct collision surface</Text>
{navigation ? <>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
</> : null}
</View>;
}
function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function ComposerPill({ theme, workspaceId, agentId }) {
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
const agent = useAgent(agentId, (value) => ({ title: value.title }));
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
}
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
let removePill = () => {};
removePill = client.addComposerPill({
id: "review",
title: "Open composer review",
workspaceId: agent.workspaceId,
agentId: agent.id,
Component: ComposerPill,
async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
removePill();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
},
});
pills.set(agent.id, removePill);
});
return () => {
unsubscribe();
for (const removePill of pills.values()) removePill();
pills.clear();
};
}
export default function contribute(plugin) {
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
@@ -64,6 +125,7 @@ export default function contribute(plugin) {
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
plugin.addClientSide(contributeClient);
return () => {};
}`;
}
@@ -94,7 +156,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
@@ -119,7 +181,10 @@ test.describe("plugin workspace panels and Command Center", () => {
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), pluginSource());
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
);
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
@@ -180,6 +245,38 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.getByTestId("plugin-surface-close").click();
});
await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
await runCommand(page, "Open direct collision surface");
await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
await page.waitForURL(isSettledWorkspaceUrl);
await expect(page.getByTestId("workspace-header-title")).toBeVisible();
const agent = await primary.client.createAgent({
provider: "mock",
cwd: primary.repoPath,
workspaceId: primary.workspaceId,
title: "Plugin navigation agent",
model: "ten-second-stream",
modeId: "load-test",
});
const navigationAgentId = agent.id;
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
);
await primaryClient.reloadPlugin(PLUGIN_ID);
await runCommand(page, "Open direct collision surface");
await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
await page.waitForURL(isSettledWorkspaceUrl);
await expect(
page
.getByTestId(`workspace-tab-agent_${navigationAgentId}`)
.filter({ visible: true })
.first(),
).toBeVisible();
});
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
@@ -207,10 +304,33 @@ test.describe("plugin workspace panels and Command Center", () => {
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await submitMessage(page, "emit 1 agent stream updates");
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText(
"Review Unrelated title update:Plugin panel context agent",
);
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await capture(page, testInfo, "plugin-composer-pill-compact");
await page.getByRole("button", { name: "Open composer review" }).click();
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Opened from composer pill",
);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
@@ -218,7 +338,6 @@ test.describe("plugin workspace panels and Command Center", () => {
).toBeVisible();
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
});
await test.step("removing the active plugin renders the panel unavailable", async () => {
+10 -15
View File
@@ -3,8 +3,8 @@ import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
expectPrPaneChecks,
expectPrPaneActivity,
} from "../support/helpers/pr-pane";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
await expectPrPaneChecks(page, {
success: ["build-1", "build-2"],
failure: ["deploy"],
pending: ["security"],
});
});
test("renders activity rows with correct count", async ({ page }) => {
test("renders every seeded activity comment", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
});
});
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
sentinel: "OPENCODE_CHILD_SENTINEL",
expectedName: "Verify OpenCode descriptor",
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
expectsUserMessage: false,
providerConfig: { model: "openai/gpt-5.4" },
prompt:
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
// Workspace B (same directory): the SAME directory-backed content is
// visible even though the panel tabs belong to a different workspace.
@@ -79,8 +79,8 @@ test.describe("Same-directory workspaces", () => {
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,108 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarNavPreferences,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
await test.step("the sidebar starts in the default order", async () => {
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
});
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"search",
"schedules",
]);
await expectSidebarNavSettingsRow(page, {
key: "history",
label: "History",
visible: true,
});
// Items with a keyboard shortcut badge it next to their name. Chords render
// with Ctrl off macOS, which is what the browser project runs on.
await expect(
page.getByTestId("sidebar-nav-item-new-workspace").getByText("Ctrl+N", { exact: true }),
).toBeVisible();
});
await test.step("moving Schedules up twice lifts it above History", async () => {
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"schedules",
"search",
]);
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"schedules",
"history",
"search",
]);
await leaveSettings(page);
await expectSidebarOrder(page, ["new-workspace", "schedules", "history", "search"]);
});
await test.step("turning History off removes it from the sidebar", async () => {
await openSidebarNavSettings(page);
await setSidebarNavItemVisible(page, "history", false);
await expectStoredSidebarNav(page, [
{ key: "new-workspace", visible: true },
{ key: "schedules", visible: true },
{ key: "history", visible: false },
{ key: "search", visible: true },
]);
await leaveSettings(page);
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
await test.step("the sidebar keeps that shape across a reload", async () => {
await page.reload();
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
});
test("renders no top-level items when every one is turned off", async ({ page }) => {
await seedSidebarNavPreferences(page, [
{ key: "new-workspace", visible: false },
{ key: "history", visible: false },
{ key: "search", visible: false },
{ key: "schedules", visible: false },
]);
await gotoAppShell(page);
// The sidebar itself still renders; only its top-level nav items are gone.
await expect(page.locator('[data-testid="sidebar-settings"]:visible')).toBeVisible({
timeout: 30_000,
});
await expectSidebarItemHidden(page, "new-workspace");
await expectSidebarItemHidden(page, "history");
await expectSidebarItemHidden(page, "search");
await expectSidebarItemHidden(page, "schedules");
});
});
@@ -13,7 +13,6 @@ import {
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastDebounced,
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
@@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => {
"true",
);
await gate.drop();
await expectReconnectingToastDebounced(page);
await expectReconnectingToastVisible(page);
await gate.waitForBlockedConnection();
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
// Hold the first authoritative catch-up response so the assertion observes
// the reconnect boundary instead of racing a socket that has not reopened yet.
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.restore();
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await expectReconnectingToastGone(page);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
@@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
@@ -0,0 +1,77 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
const TITLE_MAX_LENGTH = 80;
function normalizedTitle(title: string): string {
return title.replace(/\s+/g, " ").trim();
}
function expectedTooltipTitle(title: string): string {
const singleLineTitle = normalizedTitle(title);
return singleLineTitle.length <= TITLE_MAX_LENGTH
? singleLineTitle
: `${singleLineTitle.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…`;
}
async function openAgent(page: Page, agent: { id: string; workspaceId: string }): Promise<void> {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
}
async function openAgentTabTooltip(page: Page, agentId: string, title: string): Promise<Locator> {
await page.getByRole("button", { name: normalizedTitle(title), exact: true }).hover();
const tooltip = page.getByTestId(`workspace-tab-tooltip-agent_${agentId}`);
await expect(tooltip).toBeVisible({ timeout: 10_000 });
return tooltip;
}
async function expectTwoRowAgentSummary(
tooltip: Locator,
input: { title: string; agentId: string },
): Promise<void> {
const title = tooltip.getByText(expectedTooltipTitle(input.title), { exact: true });
const id = tooltip.getByText(input.agentId.slice(0, 7), { exact: true });
const activity = tooltip.getByText(/^(just now|\d+[mhd] ago|[A-Z][a-z]{2} \d{1,2})$/);
await expect(title).toBeVisible();
await expect(id).toBeVisible();
await expect(tooltip.getByText("·", { exact: true })).toBeVisible();
await expect(activity).toBeVisible();
expect((await title.boundingBox())?.y).toBeLessThan((await id.boundingBox())?.y ?? 0);
}
test.describe("Workspace agent tab tooltip", () => {
test("summarizes a multiline agent title above its ID and last activity", async ({ page }) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-tooltip-" });
try {
const title = [
"Short first line",
"followed by a much longer continuation that should be joined before the complete title is truncated to a useful tooltip length",
].join("\n");
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await openAgent(page, agent);
const tooltip = await openAgentTabTooltip(page, agent.id, title);
await expectTwoRowAgentSummary(tooltip, { title, agentId: agent.id });
} finally {
await workspace.cleanup();
}
});
});
@@ -0,0 +1,36 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectWorkspaceAbsentFromSidebar,
selectWorkspaceInSidebar,
} from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
expect(result.error).toBeNull();
}
test.describe("Workspace archive cache coherence", () => {
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
await archiveWorkspaceOutsideTheApp(workspace);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -6,7 +6,7 @@ import {
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import {
createIdleAgent,
createMockIdleAgent,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
try {
const agent = await createIdleAgent(workspace.client, {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
@@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await daemonGate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
@@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => {
try {
await Promise.all([
createIdleAgent(primaryWorkspace.client, {
createMockIdleAgent(primaryWorkspace.client, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title: "Active host agent",
}),
createIdleAgent(secondaryWorkspace.client, {
createMockIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: "Inactive host agent",
@@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => {
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
STATE_DIR="${PASEO_COMPOSER_KEYBOARD_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-composer-keyboard}"
ARTIFACTS_DIR="${REPO_ROOT}/.dev/agent-device-artifacts/composer-keyboard-android"
SESSION="${PASEO_COMPOSER_KEYBOARD_SESSION:-composer-keyboard-android}"
APP_ID="${PASEO_COMPOSER_KEYBOARD_APP_ID:-sh.paseo.debug}"
DEVICE="${PASEO_COMPOSER_KEYBOARD_DEVICE:-paseo-api35}"
HELPER_IME="com.callstack.agentdevice.imehelper/.TestInputMethodService"
GBOARD_IME="com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"
ASSERT="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs"
STALL_HERMES="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/stall-hermes.mjs"
METRO_PORT="${PASEO_MOBILE_E2E_METRO_PORT:-8082}"
DAEMON_HOST="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOST:-127.0.0.1:6770}"
DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e2e-home}"
SERVER_ID="${PASEO_COMPOSER_KEYBOARD_SERVER_ID:-}"
MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
if [[ -z "${SERVER_ID}" ]]; then
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
echo "Missing ${DAEMON_HOME}/server-id; set PASEO_COMPOSER_KEYBOARD_SERVER_ID" >&2
exit 1
fi
SERVER_ID="$(<"${DAEMON_HOME}/server-id")"
fi
ad() {
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device "$@" --session "${SESSION}"
}
capture_screen() {
local output_path="$1"
ad screenshot "${output_path}" >/dev/null
}
snapshot_json() {
local output_path="$1"
ad snapshot -i --json >"${output_path}"
}
capture_ui_xml() {
local output_path="$1"
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
# Android exposes one UI Automation connection at a time. Release the
# persistent agent-device snapshot helper before asking uiautomator for the
# app and IME windows; agent-device reconnects it on the next interaction.
adb shell am force-stop com.callstack.agentdevice.snapshothelper
sleep 0.2
adb shell uiautomator dump "${device_path}" >/dev/null
adb pull "${device_path}" "${output_path}" >/dev/null
}
clear_input() {
ad fill 'editable=true' "x" --settle
adb shell input keycombination 113 29
adb shell input keyevent 67
}
clear_focused_input_with_adb() {
adb shell input keycombination 113 29
adb shell input keyevent 67
}
wait_for_ime() {
local expected="$1"
local previous_top=""
local stable_count=0
for _ in $(seq 1 30); do
local window_state
window_state="$(adb shell dumpsys window)"
if [[ "${expected}" == "true" ]]; then
local ime_top
ime_top="$(printf '%s' "${window_state}" | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
if [[ -n "${ime_top}" && "${ime_top}" -gt 0 ]]; then
if [[ "${ime_top}" == "${previous_top}" ]]; then
stable_count="$((stable_count + 1))"
else
stable_count=0
previous_top="${ime_top}"
fi
if [[ "${stable_count}" -ge 2 ]]; then
return
fi
fi
elif ! printf '%s' "${window_state}" | rg -q "type=ime .*visible=true"; then
return
fi
sleep 0.1
done
echo "IME did not become visible=${expected}" >&2
exit 1
}
ime_is_visible() {
adb shell dumpsys window | rg -q "type=ime .*visible=true"
}
open_gboard() {
local x="$1"
local y="$2"
adb shell ime set "${GBOARD_IME}" >/dev/null
if ime_is_visible; then
adb shell input keyevent BACK
wait_for_ime false
fi
adb shell input tap "${x}" "${y}"
wait_for_ime true
local keyboard_shift
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${x}" "$((y - keyboard_shift))"
}
read_keyboard_shift() {
local ime_top
local navigation_top
ime_top="$(adb shell dumpsys window | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
navigation_top="$(adb shell dumpsys window | sed -n 's/.*type=navigationBars frame=\[0,\([0-9][0-9]*\)\].*/\1/p' | head -1)"
printf '%s\n' "$((navigation_top - ime_top))"
}
read_ime_top() {
adb shell dumpsys window | sed -n \
's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1
}
cleanup() {
adb shell ime set "${HELPER_IME}" >/dev/null 2>&1 || true
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
mkdir -p "${STATE_DIR}" "${ARTIFACTS_DIR}"
workspaces_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
npm run --silent cli -- workspace ls --json --host "${DAEMON_HOST}")"
workspace_id="$(node -e '
const workspaces = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
const cwd = process.argv[1];
const workspace = workspaces.find((candidate) => candidate.cwd === cwd);
if (!workspace) process.exit(1);
process.stdout.write(workspace.workspaceId);
' "${REPO_ROOT}" <<<"${workspaces_json}")"
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device open "${APP_ID}" \
--platform android \
--device "${DEVICE}" \
--session "${SESSION}"
adb shell am force-stop "${APP_ID}"
adb shell am start \
-a android.intent.action.VIEW \
-d "exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A${METRO_PORT}" \
"${APP_ID}" >/dev/null
sleep 3
# Create the fixture while the client is connected so its directory replica observes the insert.
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
--background \
--title "${AGENT_TITLE}" \
--provider mock \
--model e2e-fast-stream \
--workspace "${workspace_id}" \
--cwd "${REPO_ROOT}" \
--host "${DAEMON_HOST}" \
--json)"
agent_id="$(node -e '
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
process.stdout.write(result.agentId);
' <<<"${run_json}")"
sleep 3
adb shell am start \
-a android.intent.action.VIEW \
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
"${APP_ID}" >/dev/null
sleep 5
# The development-client bootstrap can consume the first link while Expo Router is mounting.
# Deliver the target again after the root navigator is live.
adb shell am start \
-a android.intent.action.VIEW \
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
"${APP_ID}" >/dev/null
ad wait "text=\"${AGENT_TITLE}\"" 45000
ad wait 'editable=true' 10000
clear_input
ad keyboard dismiss || true
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
echo "Composer keyboard invariants passed"
@@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import process from "node:process";
import sharp from "sharp";
const [, , command, ...args] = process.argv;
if (command === "rect") {
const [snapshotPath, identifier] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const node = snapshot.data.nodes.find(
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
);
if (!node) throw new Error(`Missing node: ${identifier}`);
const { x, y, width, height } = node.rect;
process.stdout.write(`${Math.round(x + width / 2)} ${Math.round(y + height / 2)} ${height}\n`);
} else if (command === "above-y") {
const [snapshotPath, upperIdentifier, lowerYArgument] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const upper = snapshot.data.nodes.find(
(candidate) => candidate.identifier === upperIdentifier || candidate.label === upperIdentifier,
);
if (!upper) throw new Error(`Missing node: ${upperIdentifier}`);
const upperBottom = upper.rect.y + upper.rect.height;
const lowerY = Number(lowerYArgument);
if (upperBottom > lowerY) {
throw new Error(
`${upperIdentifier} extends below the composer top: ${upperBottom} > ${lowerY}`,
);
}
} else if (command === "xml-above-y") {
const [snapshotPath, contentDescription, lowerYArgument] = args;
const snapshot = await fs.readFile(snapshotPath, "utf8");
const escapedDescription = contentDescription.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = snapshot.match(
new RegExp(
`<node[^>]*content-desc="${escapedDescription}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`,
),
);
if (!match) throw new Error(`Missing node: ${contentDescription}`);
const upperBottom = Number(match[4]);
const lowerY = Number(lowerYArgument);
if (upperBottom > lowerY) {
throw new Error(`${contentDescription} extends below the keyboard: ${upperBottom} > ${lowerY}`);
}
} else if (command === "same-header") {
const [baselinePath, keyboardPath, headerBottomArgument] = args;
const headerBottom = Number(headerBottomArgument);
const crop = { left: 0, top: 64, width: 1080, height: headerBottom - 64 };
const [baseline, keyboard] = await Promise.all([
sharp(baselinePath).extract(crop).removeAlpha().raw().toBuffer(),
sharp(keyboardPath).extract(crop).removeAlpha().raw().toBuffer(),
]);
let changed = 0;
for (let index = 0; index < baseline.length; index += 1) {
if (Math.abs(baseline[index] - keyboard[index]) > 8) changed += 1;
}
const changedRatio = changed / baseline.length;
if (changedRatio > 0.01) {
throw new Error(`Header changed while the keyboard opened (${changedRatio.toFixed(3)})`);
}
} else if (command === "same-input-height") {
const [firstPath, secondPath] = args;
const readInputHeight = async (snapshotPath) => {
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
if (!input) throw new Error(`Missing composer input in ${snapshotPath}`);
return input.rect.height;
};
const [firstHeight, secondHeight] = await Promise.all([
readInputHeight(firstPath),
readInputHeight(secondPath),
]);
if (firstHeight !== secondHeight) {
throw new Error(`Composer height drifted from ${firstHeight} to ${secondHeight}`);
}
} else if (command === "has-exact-text") {
const [snapshotPath, expected] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
}
} else if (command === "same-region") {
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
const crop = {
left: 0,
top: Number(topArgument),
width: 1080,
height: Number(heightArgument),
};
const [first, second] = await Promise.all([
sharp(firstPath).extract(crop).removeAlpha().raw().toBuffer(),
sharp(secondPath).extract(crop).removeAlpha().raw().toBuffer(),
]);
let changed = 0;
for (let index = 0; index < first.length; index += 1) {
if (Math.abs(first[index] - second[index]) > 8) changed += 1;
}
const changedRatio = changed / first.length;
if (changedRatio > 0.02) {
throw new Error(
`${failureMessage ?? "Composer region changed during the JS stall"} (${changedRatio.toFixed(3)})`,
);
}
} else {
throw new Error(`Unknown command: ${command}`);
}
@@ -0,0 +1,35 @@
import process from "node:process";
import { WebSocket } from "ws";
const [, , metroPortArgument, durationArgument, appId] = process.argv;
const metroPort = Number(metroPortArgument);
const duration = Number(durationArgument);
const targets = await fetch(`http://127.0.0.1:${metroPort}/json/list`).then((response) =>
response.json(),
);
const target = targets.find((candidate) => candidate.appId === appId);
if (!target) throw new Error(`Missing Hermes target for ${appId}`);
await new Promise((resolve, reject) => {
const socket = new WebSocket(target.webSocketDebuggerUrl);
const timeout = setTimeout(() => reject(new Error("Hermes stall timed out")), duration + 5000);
socket.on("open", () => {
socket.send(
JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: {
expression: `(() => { const deadline = Date.now() + ${duration}; while (Date.now() < deadline) {} })()`,
},
}),
);
});
socket.on("message", (message) => {
const response = JSON.parse(message.toString());
if (response.id !== 1) return;
clearTimeout(timeout);
socket.close();
resolve();
});
socket.on("error", reject);
});
+9 -2
View File
@@ -34,13 +34,20 @@ const metroTest = base.extend({
const daemonTest = metroTest.extend<
{ projectOwnership: void },
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
{
e2eForkProviders: string[];
e2eInjectPaseoTools: boolean;
e2eWorker: void;
e2eWorkerClient: SeedDaemonClient;
}
>({
e2eForkProviders: [[], { scope: "worker", option: true }],
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
e2eWorker: [
async ({ e2eForkProviders }, provide, workerInfo) => {
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
const worker = await startE2EWorker(workerInfo.workerIndex, {
forkProviders: e2eForkProviders,
injectPaseoTools: e2eInjectPaseoTools,
});
try {
await provide();
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
@@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
interface PositionedToolCall {
locator: Locator;
bounds: { x: number; y: number; width: number; height: number };
}
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
const entries = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
locator: toolCalls.nth(index),
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
return entries.filter(
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
);
}
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
return toolCalls.toSorted(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
)[0];
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return getVisibleChatScroll(page).evaluate((root: Element) => {
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
@@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
const visibleButtonBounds = buttonBounds!;
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
const toolCallBounds = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
index,
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
const candidate = toolCallBounds
.filter(
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
entry.bounds !== null && entry.bounds.width > 0,
)
.sort(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
)[0];
const initialToolCalls = await readRenderedToolCalls(toolCalls);
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
expect(
candidate,
initialCandidate,
`Expected at least one rendered tool-call badge: ${JSON.stringify({
buttonBounds,
scrollMetrics: await readScrollMetrics(page),
toolCallBounds,
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
})}`,
).toBeDefined();
const visibleToolCall = candidate!;
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
(scroll as HTMLElement).scrollTop += deltaY;
}, initialToolCallCenterY - buttonCenterY);
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
await expect
.poll(async () => {
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
if (!currentButtonBounds || !currentToolCallBounds) {
return false;
}
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
return (
const currentButtonBounds = await scrollToBottomButton.boundingBox();
if (!currentButtonBounds) return false;
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
const closest = closestToolCallToY(
await readRenderedToolCalls(toolCalls),
currentButtonCenterY,
);
if (!closest) return false;
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
if (
toolCallCenterY >= currentButtonBounds.y &&
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
);
) {
return true;
}
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
(scroll as HTMLElement).scrollTop += delta;
}, deltaY);
return false;
})
.toBe(true);
const currentButtonBounds = await scrollToBottomButton.boundingBox();
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
const alignedToolCall = closestToolCallToY(
await readRenderedToolCalls(toolCalls),
currentButtonCenterY,
)?.locator;
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
alignedToolCall!.boundingBox(),
]);
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
expect(
@@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
x: finalToolCallBounds.x + 24,
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
};
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return hit !== null && toolCall.contains(hit);
}, clickPoint);
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
): Promise<{ status: string }>;
}
export async function createIdleAgent(
async function seedIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
provider: {
provider: string;
model: string;
modeId: string;
featureValues?: Record<string, unknown>;
},
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
// auto_accept for unattended full access — mode validation now rejects modes
// the provider doesn't define.
modeId: "build",
featureValues: { auto_accept: true },
...provider,
cwd: input.cwd,
workspaceId: input.workspaceId,
title: input.title,
@@ -84,6 +84,32 @@ export async function createIdleAgent(
};
}
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
return seedIdleAgent(client, input, {
provider: "opencode",
model: "opencode/gpt-5-nano",
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
// auto_accept for unattended full access — mode validation now rejects modes
// the provider doesn't define.
modeId: "build",
featureValues: { auto_accept: true },
});
}
export async function createMockIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
return seedIdleAgent(client, input, {
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
});
}
export async function archiveAgentFromDaemon(
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
agentId: string,
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
}
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Meta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
await page.getByTestId("sidebar-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
@@ -38,6 +38,10 @@ export async function expectComposerEditable(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
}
export async function expectComposerFocused(page: Page): Promise<void> {
await expect(composerInput(page)).toBeFocused();
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -49,6 +53,10 @@ export async function fillComposerDraft(page: Page, text: string): Promise<void>
await composerInput(page).fill(text);
}
export async function typeIntoFocusedComposer(page: Page, text: string): Promise<void> {
await page.keyboard.type(text);
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
@@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) {
const suppressedAgentStreamEventTypes = new Set<string>();
const suppressedAgentStreamItemTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let blockedConnectionCount = 0;
let blockedConnectionCountAtDrop = 0;
const blockedConnectionWaiters = new Set<() => void>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
@@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
blockedConnectionCount += 1;
for (const resolve of blockedConnectionWaiters) resolve();
blockedConnectionWaiters.clear();
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
return;
}
@@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) {
forward?.();
},
async drop(): Promise<void> {
blockedConnectionCountAtDrop = blockedConnectionCount;
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
@@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) {
),
);
},
async waitForBlockedConnection(): Promise<void> {
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
},
restore(): void {
acceptingConnections = true;
},
+10 -7
View File
@@ -25,13 +25,16 @@ export async function expectDiagramWithLabels(
}
}
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await page.waitForTimeout(25);
export async function expectDiagramRemainsRenderedWhileStreaming(
page: Page,
completion: Promise<void>,
): Promise<void> {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
const completed = completion.then(() => true);
for (;;) {
expect(await diagram.isVisible()).toBe(true);
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
if (didComplete) return completion;
}
}
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
}
async function openCommandCenter(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open command center" }).click();
await page.getByTestId("sidebar-search").click();
}
export class DirectoryBootstrapScenario {
+30 -1
View File
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -155,7 +156,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
export async function startE2EWorker(
workerIndex: number,
options: { forkProviders?: string[] } = {},
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
): Promise<E2EWorker> {
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
const paseoHome = requestedRoot
@@ -168,6 +169,9 @@ export async function startE2EWorker(
try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
if (options.injectPaseoTools) {
await enablePaseoTools(paseoHome);
}
const daemon = await startIsolatedHostDaemon(serverId, {
paseoHome,
preserveHome,
@@ -201,3 +205,28 @@ export async function startE2EWorker(
throw error;
}
}
async function enablePaseoTools(paseoHome: string): Promise<void> {
const configPath = path.join(paseoHome, "config.json");
const existing = existsSync(configPath)
? JSON.parse(await readFile(configPath, "utf8"))
: { version: 1 };
await writeFile(
configPath,
`${JSON.stringify(
{
...existing,
daemon: {
...existing.daemon,
mcp: {
...existing.daemon?.mcp,
enabled: true,
injectIntoAgents: true,
},
},
},
null,
2,
)}\n`,
);
}
+24 -17
View File
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { CheckPresentationCounts } from "@/git/check-presentation";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
@@ -20,23 +19,31 @@ export async function expectPrPaneState(
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
export async function expectPrPaneChecks(
page: Page,
checks: { success: string[]; failure: string[]; pending: string[] },
): Promise<void> {
const section = page.getByTestId("pr-pane-checks");
const groups = [
{ status: "failure", label: /failing checks?$/, names: checks.failure },
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
{ status: "success", label: /successful checks?$/, names: checks.success },
];
for (const group of groups) {
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
timeout: 15_000,
});
for (const name of group.names) {
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
}
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-success", counts.success);
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
const activity = page.getByTestId("pr-pane-activity-row");
for (const body of bodies) {
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
}
}
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-row-store";
const STORE_NAME = "rows";
@@ -116,6 +116,23 @@ export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord |
return rows.length > 0 ? assembleCache(rows) : null;
}
export async function waitForWorkspaceInReplicaCache(
page: Page,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const cache = await readReplicaCache(page);
return cache?.hosts?.some((host) =>
host.workspaces.some((workspace) => workspace.id === workspaceId),
);
},
{ timeout: 15_000 },
)
.toBe(true);
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
const rows: ReplicaRowRecord[] = [];
for (const host of value.hosts ?? []) {
@@ -69,6 +69,7 @@ export interface SeedDaemonClient {
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
}>;
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
/**
* Force the daemon to recompute its git snapshot and diff for a checkout,
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
@@ -0,0 +1,148 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Persisted nav key -> the testID the app shell renders that item with. */
const SHELL_ROW_TEST_IDS = {
"new-workspace": "sidebar-global-new-workspace",
history: "sidebar-sessions",
search: "sidebar-search",
schedules: "sidebar-schedules",
} as const;
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
export interface SidebarNavPreference {
key: string;
visible: boolean;
}
function shellRow(page: Page, key: SidebarNavKey): Locator {
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
// sidebar mounted, so the pinned row is the first visible match.
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
}
function settingsRow(page: Page, key: SidebarNavKey): Locator {
return page.getByTestId(`sidebar-nav-item-${key}`);
}
function itemLabel(key: SidebarNavKey): string {
return {
"new-workspace": "New workspace",
history: "History",
search: "Search",
schedules: "Schedules",
}[key];
}
async function rowTop(locator: Locator): Promise<number | null> {
const box = await locator.boundingBox();
return box?.y ?? null;
}
export async function seedSidebarNavPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarNavItems }) => {
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
},
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
}
export async function leaveSettings(page: Page): Promise<void> {
await clickSettingsBackToWorkspace(page);
}
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function setSidebarNavItemVisible(
page: Page,
key: SidebarNavKey,
visible: boolean,
): Promise<void> {
const toggle = settingsRow(page, key).getByRole("switch", {
name: itemLabel(key),
exact: true,
});
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
export async function expectSidebarNavSettingsRow(
page: Page,
expected: { key: SidebarNavKey; label: string; visible: boolean },
): Promise<void> {
const row = settingsRow(page, expected.key);
await expect(row).toBeVisible();
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
await expect(toggle).toHaveAccessibleName(expected.label);
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
}
export async function expectSidebarNavSettingsOrder(
page: Page,
keys: SidebarNavKey[],
): Promise<void> {
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
}
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
}
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
}
export async function expectStoredSidebarNav(
page: Page,
expected: SidebarNavPreference[],
): Promise<void> {
await expect
.poll(
() =>
page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
}, APP_SETTINGS_KEY),
{ timeout: 15_000 },
)
.toEqual(expected);
}
async function expectVerticalOrder(
keys: SidebarNavKey[],
locate: (key: SidebarNavKey) => Locator,
subject: string,
): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
const laidOut = measured.filter(
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
);
if (laidOut.length !== keys.length) return null;
return laidOut.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
{ message: `Expected ${subject} in order`, timeout: 15_000 },
)
.toEqual(keys);
}
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
});
}
export async function openChangesPanel(page: Page): Promise<void> {
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
await openChangesTreePanel(page);
const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await expect(changedFile).toBeVisible({ timeout });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
timeout,
});
}
@@ -112,14 +112,6 @@ export async function expectReconnectingToastGone(
});
}
export async function expectReconnectingToastDebounced(
page: Page,
options?: { durationMs?: number },
): Promise<void> {
await page.waitForTimeout(options?.durationMs ?? 750);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -162,5 +162,10 @@
"vitest": "^4.1.6",
"wrangler": "^4.105.0",
"ws": "^8.20.0"
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
}
}
}
@@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
@@ -0,0 +1,47 @@
import { useMemo } from "react";
import type { StreamItem } from "@/types/stream";
import type { StreamHistoryRowRevision } from "./strategy";
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
}
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
let variants = historyRowDisplayVariants.get(item);
if (!variants) {
variants = {};
historyRowDisplayVariants.set(item, variants);
}
const key = compact ? "compact" : "regular";
variants[key] ??= { ...item };
return variants[key];
}
// Item identity is the render signal for history rows: the row boundary in view.tsx bails out
// until its item changes. Every viewport runs its history through this hook so a history host
// whose tool-call group keeps updating from the live head, a group whose expanded state changed,
// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity,
// which is what limits a live update to the rows it touched.
export function useRevisedHistoryRows(
items: StreamItem[],
revision: StreamHistoryRowRevision | undefined,
): StreamItem[] {
const globalDisplayState = revision?.globalDisplayState ?? false;
const displayStateById = revision?.displayStateById;
const contentById = revision?.contentById;
const globallyRevisedRows = useMemo(
() => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)),
[items, globalDisplayState],
);
const displayStateRevisedRows = useMemo(
() => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)),
[globallyRevisedRows, displayStateById],
);
return useMemo(
() => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)),
[displayStateRevisedRows, contentById],
);
}
+17 -1
View File
@@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
@@ -366,6 +365,23 @@ describe("layoutStream", () => {
},
);
it("keeps layout item identity for rows whose layout did not change", () => {
const user = userMessage("u1", 1);
const first = toolCall("tool-1", 2);
const second = toolCall("tool-2", 3);
const before = layoutFor({ platform: "android", tail: [user, first, second] });
const third = toolCall("tool-3", 4);
const after = layoutFor({ platform: "android", tail: [user, first, second, third] });
// A newest-first list shifts every index, but only the rows next to the insertion change.
expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id));
expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id));
expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id));
expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false);
expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true);
});
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
+102 -55
View File
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence:
isToolSequenceItem(item) &&
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
frameOrder: input.frameOrder,
phase: input.phase,
};
function areTurnFooterHostsEqual(
left: TurnFooterHost | null,
right: TurnFooterHost | null,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return (
left.itemId === right.itemId &&
left.timing === right.timing &&
left.startIndex === right.startIndex &&
left.items === right.items
);
}
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
return (
previous.item === next.item &&
previous.aboveItem === next.aboveItem &&
previous.belowItem === next.belowItem &&
previous.gapBelow === next.gapBelow &&
previous.assistantSpacing === next.assistantSpacing &&
areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) &&
previous.toolSequence === next.toolSequence &&
previous.isFirstInUserGroup === next.isFirstInUserGroup &&
previous.isLastInUserGroup === next.isLastInUserGroup &&
previous.isLastInToolSequence === next.isLastInToolSequence &&
previous.frameOrder === next.frameOrder &&
previous.phase === next.phase
);
}
function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem {
const previous = previousLayoutItemByStreamItem.get(next.item);
if (previous && areLayoutItemsEquivalent(previous, next)) {
return previous;
}
previousLayoutItemByStreamItem.set(next.item, next);
return next;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
}
function layoutSegmentItem(
input: LayoutSegmentInput,
item: StreamItem,
index: number,
): StreamLayoutItem {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return shareLayoutItem({
item,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence:
isToolSequenceItem(item) &&
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
frameOrder: input.frameOrder,
phase: input.phase,
});
}
+27 -3
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
function createTimestamp(seed: number): Date {
@@ -69,12 +71,36 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
startedAt: tail[2]?.timestamp,
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
});
it("keeps the mounted boundary stable when a transformer filters an earlier item", () => {
const tail = [
userMessage("filtered", 1),
assistantMessage("hidden", 2),
userMessage("visible-u", 3),
assistantMessage("visible-a", 4),
];
const projectedTail = projectPluginTimelineItems(tail, ({ sourceId }) =>
sourceId === "filtered" ? [] : undefined,
);
const historyStart = findMountedWindowStart({ items: projectedTail, minMountedCount: 2 });
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail: projectedTail,
head: [],
platform: "native",
isMobileBreakpoint: false,
historyStart,
});
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
});
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
@@ -176,7 +202,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
@@ -196,7 +221,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
};
const HISTORY_START_SETTLE_FRAMES = 2;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
}
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
let variants = historyRowDisplayVariants.get(item);
if (!variants) {
variants = {};
historyRowDisplayVariants.set(item, variants);
}
const key = compact ? "compact" : "regular";
variants[key] ??= { ...item };
return variants[key];
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
// Keep unchanged item identities intact so live updates only rerender rows
// whose projected content or local display state actually changed. A rare
// breakpoint change intentionally refreshes the whole history window.
const globallyRevisedHistoryRows = useMemo(() => {
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
}, [historyItems, historyRowRevision?.globalDisplayState]);
const displayStateHistoryRows = useMemo(
() =>
globallyRevisedHistoryRows.map((item) =>
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
),
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
);
const historyRows = useMemo(
() =>
displayStateHistoryRows.map((item) =>
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("rerenders only the history row whose content revision changed", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
const host = userMessage(1);
const other = userMessage(2);
const renderCountById = new Map<string, number>();
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
return <div>{item.id}</div>;
});
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [host, other],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: {
...createRenderers(vi.fn()),
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
},
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
const unrevised = {
contentById: new Set<string>(),
displayStateById: new Set<string>(),
globalDisplayState: false,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
});
expect(renderCountById.get(host.id)).toBe(1);
expect(renderCountById.get(other.id)).toBe(1);
act(() => {
root?.render(
strategy.render({
...renderInput,
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
}),
);
});
expect(renderCountById.get(host.id)).toBe(2);
expect(renderCountById.get(other.id)).toBe(1);
});
it("keeps a row mounted when it moves from the live head into mounted history", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
+12 -1
View File
@@ -20,6 +20,7 @@ import {
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments,
segments: inputSegments,
historyRowRevision,
liveHeadRowRevision,
boundary,
renderers,
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
inputSegments.historyVirtualized,
historyRowRevision,
);
const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision);
const segments = useMemo(
() => ({ ...inputSegments, historyVirtualized, historyMounted }),
[historyMounted, historyVirtualized, inputSegments],
);
const isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
assistant("heartbeat-2-reply", 6, "turn-3"),
];
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
});
});
it.each([
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
startedAt: at(1),
completedAt: at(9),
durationMs: 8000,
});
+45 -9
View File
@@ -107,7 +107,8 @@ import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
import { PluginTimelineItemView } from "@/plugins/timeline";
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -220,6 +221,22 @@ function renderListEmptyComponent(input: {
);
}
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const HistoryStreamRow = memo(function HistoryStreamRow({
layoutItem,
renderStreamItem,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}) {
return <>{renderStreamItem(layoutItem)}</>;
});
function renderHistoryStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
@@ -229,7 +246,13 @@ function renderHistoryStreamItem(input: {
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
return (
<HistoryStreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
}
function renderLiveHeadStreamItem(input: {
@@ -353,6 +376,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const sessionStreamHead = useSessionStore((state) =>
@@ -535,6 +559,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolCallDetailLevel,
],
);
const projectedPlugins = useMemo(
() => ({
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
}),
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
);
const {
start: historyWindowStart,
hasLocalHistory,
@@ -542,7 +573,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: projectedToolCalls.tail,
items: projectedPlugins.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
@@ -553,8 +584,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
tail: projectedPlugins.tail,
head: projectedPlugins.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
@@ -562,8 +593,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [
isMobile,
isTurnActive,
projectedToolCalls.head,
projectedToolCalls.tail,
projectedPlugins.head,
projectedPlugins.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
@@ -789,9 +820,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
const getToolCallGroup = useStableEvent((hostId: string) =>
projectedToolCalls.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = projectedToolCalls.groupsByHostId.get(item.id);
const group = getToolCallGroup(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
@@ -818,8 +854,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
},
[
projectedToolCalls.groupsByHostId,
expandedToolCallGroupIds,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
],
+7 -6
View File
@@ -39,6 +39,7 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
@@ -94,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider";
import {
KeyboardActionDispatcherProvider,
@@ -113,7 +114,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
@@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -625,10 +627,9 @@ function SidebarChrome({
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const active = visible && isOpen;
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
return (
<SidebarModelProvider active={active}>
{mounted ? <LeftSidebar active={active} /> : null}
@@ -56,6 +56,7 @@ import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider";
import { filterAndRankWorkspaces } from "./workspace-search";
import {
buildContributionSections,
filterAndRankBuiltInResults,
@@ -121,10 +122,6 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
@@ -158,6 +155,7 @@ function useBuiltInRows(open: boolean): {
project.projectName,
workspace.currentBranch,
]),
changeRequestNumber: workspace.changeRequestNumber,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -208,12 +206,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankBuiltInResults(
rows.workspaces,
query,
workspaceSearchFields,
compareWorkspacesByTitle,
),
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
},
{
id: "agents",
@@ -102,7 +102,14 @@ function autoModeChoice(): CommandCenterContribution {
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
return { kind: "workspace", id, title, subtitle, run: () => undefined };
return {
kind: "workspace",
id,
title,
subtitle,
changeRequestNumber: null,
run: () => undefined,
};
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
@@ -48,6 +48,8 @@ export interface CommandCenterWorkspaceResult {
id: string;
title: string;
subtitle: string;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
run(): void;
}
@@ -9,17 +9,19 @@ import {
History,
Home,
Keyboard,
PanelLeft,
Plus,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
@@ -54,6 +56,9 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
@@ -91,6 +96,10 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
}
function PanelLeftIcon({ size }: CommandCenterIconProps) {
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -106,6 +115,10 @@ export function CommandCenterRootActions() {
// each time host filters are reconciled.
const groupMode = useSidebarViewStore((state) => state.groupMode);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const isCompact = useIsCompactFormFactor();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
@@ -227,6 +240,33 @@ export function CommandCenterRootActions() {
undefined,
},
},
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
// The right sidebar and focus toggles do NOT belong here — their handlers live in
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
// would list entries that silently no-op off a workspace route. They live in
// workspace-contributions.ts instead. That is why the three toggles render in two
// non-adjacent sections; don't "tidy" them back together.
{
id: "toggle-left-sidebar",
group: "actions",
groupRank: 0,
rank: 7,
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
toggleAgentList();
},
presentation: {
kind: "action",
title: t("settings.shortcuts.help.toggleLeftSidebar"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PanelLeftIcon,
shortcutKeys:
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
undefined,
},
},
];
if (shortcutsAvailable) {
@@ -278,6 +318,7 @@ export function CommandCenterRootActions() {
shortcutPlatform,
shortcutsAvailable,
t,
toggleAgentList,
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
@@ -23,9 +23,14 @@ function source(gitActions: GitActions): {
value: WorkspaceCommandCenterSource;
runGitActions: GitAction[];
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
return {
value: {
gitActions,
@@ -62,19 +67,51 @@ function source(gitActions: GitActions): {
moveTabDown: "Move tab down",
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle side panel",
toggleExplorerSidebar: "Toggle Explorer sidebar",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
pin: "Pin to top",
unpin: "Unpin",
showSetup: "Show setup",
labelsGroup: "Labels",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
capabilities: {
canSplitPanes: true,
canOpenBrowserTabs: true,
isGit: false,
canPin: false,
canShowSetup: false,
},
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
currentBranch: null,
isPinned: false,
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
copyPath: () => {
counters.copiedPaths += 1;
},
copyBranchName: () => {
counters.copiedBranchNames += 1;
},
toggleLabel: (name, assigned) => {
toggledLabels.push({ name, assigned });
},
},
runGitActions,
dispatched,
toggledLabels,
get copiedPaths() {
return counters.copiedPaths;
},
get copiedBranchNames() {
return counters.copiedBranchNames;
},
};
}
@@ -147,6 +184,8 @@ describe("workspace command center contributions", () => {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
canPin: false,
canShowSetup: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
@@ -155,6 +194,8 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -164,7 +205,14 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
// workspace:rename dispatches to workspace scope
expect(
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
).toBe(true);
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
expect(
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -179,4 +227,164 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
it("dispatches rename to the workspace scope", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const rename = contributions.find((item) => item.id === "workspace:rename");
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
rename?.run();
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
});
it("copies the workspace path without going through the dispatcher", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:copy-path")?.run();
expect(fixture.copiedPaths).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
(item) => item.id === "workspace:copy-branch-name",
),
).toBe(false);
const withBranch = source({ primary: null, secondary: [], menu: [] });
withBranch.value.currentBranch = "feature/login";
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
(item) => item.id === "workspace:copy-branch-name",
);
expect(contribution?.keywords).toContain("feature/login");
contribution?.run();
expect(withBranch.copiedBranchNames).toBe(1);
});
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
const unpinned = source({ primary: null, secondary: [], menu: [] });
unpinned.value.capabilities.canPin = true;
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
(item) => item.id === "workspace:pin",
);
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
expect(pinContribution?.visibility).toBe("always");
pinContribution?.run();
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
const pinned = source({ primary: null, secondary: [], menu: [] });
pinned.value.capabilities.canPin = true;
pinned.value.isPinned = true;
expect(
buildWorkspaceCommandCenterContributions(pinned.value).find(
(item) => item.id === "workspace:pin",
)?.presentation,
).toMatchObject({ title: "Unpin" });
const unsupported = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(unsupported.value).some(
(item) => item.id === "workspace:pin",
),
).toBe(false);
});
it("lists Show setup only when the workspace has setup to show", () => {
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
(item) => item.id === "workspace:show-setup",
),
).toBe(false);
const withSetup = source({ primary: null, secondary: [], menu: [] });
withSetup.value.capabilities.canShowSetup = true;
buildWorkspaceCommandCenterContributions(withSetup.value)
.find((item) => item.id === "workspace:show-setup")
?.run();
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
});
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
// root set, where they would silently no-op off a workspace route.
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = false;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
expect(fixture.dispatched).toEqual([
{ id: "sidebar.toggle.right", scope: "sidebar" },
{ id: "workspace.focus.toggle", scope: "workspace" },
]);
for (const contribution of contributions) {
expect(contribution.group).toBe("workspace");
}
});
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
// panes are available, so the standalone entry must step aside there — otherwise the palette
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
it("omits the standalone focus toggle when the pane set already covers it", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const focusToggles = contributions.filter(
(item) =>
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
);
expect(focusToggles).toHaveLength(1);
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
});
it("omits the labels group when the catalog hasn't loaded", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
});
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.labelCatalog = [
{ name: "bug", assigned: true },
{ name: "urgent", assigned: false },
];
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const bug = contributions.find((item) => item.id === "workspace:label:bug");
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
expect(bug?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "bug"],
selected: true,
});
expect(urgent?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "urgent"],
selected: false,
});
bug?.run();
urgent?.run();
expect(fixture.toggledLabels).toEqual([
{ name: "bug", assigned: false },
{ name: "urgent", assigned: true },
]);
});
});
@@ -8,6 +8,13 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface WorkspaceCommandCenterLabelChoice {
name: string;
/** Whether the current workspace already carries this label. */
assigned: boolean;
icon?: CommandCenterIcon;
}
export interface WorkspaceCommandCenterLabels {
section: string;
newAgent: string;
@@ -42,6 +49,14 @@ export interface WorkspaceCommandCenterLabels {
closePane: string;
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
rename: string;
copyPath: string;
copyBranchName: string;
pin: string;
unpin: string;
showSetup: string;
labelsGroup: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -63,6 +78,14 @@ export interface WorkspaceCommandCenterIcons {
moveTab?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
unpin?: CommandCenterIcon;
showSetup?: CommandCenterIcon;
toggleFocusMode?: CommandCenterIcon;
label?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts {
closePane?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
pinWorkspace?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -89,12 +113,27 @@ export interface WorkspaceCommandCenterSource {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
/** Host supports the `workspacePinning` feature. */
canPin: boolean;
/** The workspace has setup commands or a setup error — same gate as the header menu. */
canShowSetup: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
currentBranch: string | null;
isPinned: boolean;
/**
* The host's label catalog, each entry told whether the current workspace carries it. Null
* before the catalog has loaded — omits the whole group rather than showing it empty.
*/
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
}
function buildGitContribution(
@@ -459,6 +498,35 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildWorkspaceCallback(input: {
source: WorkspaceCommandCenterSource;
id: string;
rank: number;
title: string;
keywords: readonly string[];
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
run: () => void;
visibility: "always" | "query";
}): CommandCenterContribution {
return {
id: input.id,
group: "workspace",
groupRank: -1,
rank: input.rank,
keywords: input.keywords,
visibility: input.visibility,
run: input.run,
presentation: {
kind: "action",
title: input.title,
sectionTitle: input.source.labels.section,
icon: input.icon,
shortcutKeys: input.shortcutKeys,
},
};
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
@@ -511,20 +579,150 @@ export function buildWorkspaceCommandCenterContributions(
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:pin",
rank: 20,
title: source.isPinned ? source.labels.unpin : source.labels.pin,
keywords: ["pin", "unpin", "favorite", "sticky"],
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
shortcutKeys: source.shortcuts.pinWorkspace,
action: { id: "workspace.pin", scope: "sidebar" },
visibility: "always",
}),
);
}
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:rename",
rank: 21,
title: source.labels.rename,
keywords: ["rename", "title", "name", "label"],
icon: source.icons.rename,
action: { id: "workspace.rename", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceCallback({
source,
id: "workspace:copy-path",
rank: 22,
title: source.labels.copyPath,
keywords: ["copy", "path", "directory", "folder", "cwd"],
icon: source.icons.copyPath,
run: source.copyPath,
visibility: "query",
}),
);
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
if (source.currentBranch) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:copy-branch-name",
rank: 23,
title: source.labels.copyBranchName,
keywords: ["copy", "branch", "git", source.currentBranch],
icon: source.icons.copyBranchName,
run: source.copyBranchName,
visibility: "query",
}),
);
}
if (source.capabilities.canShowSetup) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:show-setup",
rank: 24,
title: source.labels.showSetup,
keywords: ["setup", "provision", "install", "bootstrap"],
icon: source.icons.showSetup,
action: { id: "workspace.setup.show", scope: "workspace" },
visibility: "query",
}),
);
}
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
// global registration would list two entries that silently no-op on /settings, /sessions,
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-explorer-sidebar",
rank: 25,
title: source.labels.toggleExplorerSidebar,
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "sidebar" },
visibility: "query",
}),
);
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
if (!source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-focus-mode",
rank: 26,
title: source.labels.toggleFocusMode,
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
icon: source.icons.toggleFocusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
visibility: "query",
}),
);
}
contributions.push(...buildLabelContributions(source));
return contributions;
}
/**
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
*/
function buildLabelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (!source.labelCatalog) return [];
return source.labelCatalog.map((choice, index) => ({
id: `workspace:label:${choice.name}`,
group: "workspace",
groupRank: -1,
rank: 30 + index,
keywords: ["label", "tag", choice.name],
visibility: "query" as const,
run: () => source.toggleLabel(choice.name, !choice.assigned),
presentation: {
kind: "choice" as const,
path: [source.labels.labelsGroup, choice.name] as const,
icon: choice.icon,
selected: choice.assigned,
testId: `command-center-workspace-label-${choice.name}`,
},
}));
}
@@ -1,4 +1,4 @@
import { useMemo, type ReactElement } from "react";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownToLine,
@@ -8,12 +8,16 @@ import {
Copy,
Files,
Focus,
GitBranch,
GitCompareArrows,
GitPullRequest,
Globe,
ListChecks,
Move,
PanelRight,
Pencil,
Pin,
PinOff,
RotateCw,
Rows2,
SquarePen,
@@ -25,23 +29,35 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useHostFeature } from "@/runtime/host-features";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
buildWorkspaceLabelPickerRows,
useWorkspaceLabelProjection,
workspaceLabelErrorMessage,
workspaceLabels,
} from "@/workspace-labels";
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { getCommandCenterIcon } from "./icon";
import type { CommandCenterIcon } from "./contributions";
import { useCommandCenterActions } from "./provider";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterLabelChoice,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
@@ -65,6 +81,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
moveTab: getCommandCenterIcon(Move),
focusMode: getCommandCenterIcon(ArrowDownToLine),
explorerSidebar: getCommandCenterIcon(PanelRight),
// Workspace management action icons
copyPath: getCommandCenterIcon(Copy),
copyBranchName: getCommandCenterIcon(GitBranch),
pin: getCommandCenterIcon(Pin),
unpin: getCommandCenterIcon(PinOff),
showSetup: getCommandCenterIcon(ListChecks),
toggleFocusMode: getCommandCenterIcon(Focus),
};
const OPEN_PANEL_LABEL_KEYS = {
@@ -86,6 +109,58 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
/**
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
* the sidebar's own picker.
*/
function useWorkspaceLabelCatalog(
serverId: string | null,
fields: { id: string; labels: readonly string[] } | null,
): {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
toggleLabel: (name: string, assigned: boolean) => void;
} {
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
serverId ?? undefined,
);
const toast = useToast();
const assignedLabels = fields?.labels;
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
() =>
labelHost?.status === "online" && assignedLabels
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
(row) => ({
name: row.name,
assigned: row.assigned,
icon: getLabelCommandCenterIcon(row.color),
}),
)
: null,
[assignedLabels, labelDefinitions, labelHost?.status],
);
const toggleLabel = useCallback(
async (name: string, assigned: boolean) => {
if (!serverId || !fields) return;
const definition = labelDefinitions.find((label) => label.name === name);
if (!definition) return;
try {
await workspaceLabels.setAssignment({
serverId,
workspaceId: fields.id,
label: definition,
assigned,
});
} catch (cause) {
toast.error(workspaceLabelErrorMessage(cause));
}
},
[fields, labelDefinitions, serverId, toast],
);
return { labelCatalog, toggleLabel };
}
export function useWorkspaceCommandCenterActions(): void {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -104,8 +179,29 @@ export function useWorkspaceCommandCenterActions(): void {
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
// One narrow projection for the workspace management contribution fields. The registry's snapshot
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const currentBranch = fields?.currentBranch ?? null;
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
@@ -113,6 +209,27 @@ export function useWorkspaceCommandCenterActions(): void {
icons: GIT_ACTION_ICONS,
});
const runGitAction = useGitActionRunner();
const clipboard = useWorkspaceClipboardActions();
const copyPath = useCallback(() => {
if (!fields) return;
clipboard.copyPath({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const copyBranchName = useCallback(() => {
if (!fields) return;
clipboard.copyBranchName({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
const actions = useMemo(
() =>
@@ -152,6 +269,14 @@ export function useWorkspaceCommandCenterActions(): void {
closePane: t("settings.shortcuts.help.closePane"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
pin: t("sidebar.workspace.actions.pin"),
unpin: t("sidebar.workspace.actions.unpin"),
showSetup: t("workspace.header.actions.showSetup"),
labelsGroup: t("workspaceLabels.title"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -162,27 +287,43 @@ export function useWorkspaceCommandCenterActions(): void {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
canPin,
canShowSetup,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
currentBranch,
isPinned,
labelCatalog,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
activeTabKind,
canPin,
canShowSetup,
copyBranchName,
copyPath,
currentBranch,
focusedTabs.length,
gitActions,
isCompact,
isGit,
isPinned,
keyboardActionDispatcher,
labelCatalog,
overrides,
runGitAction,
t,
toggleLabel,
],
);
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterWorkspaceResult } from "./results";
import { filterAndRankWorkspaces } from "./workspace-search";
function workspace(input: {
id: string;
title?: string;
subtitle?: string;
changeRequestNumber?: number | null;
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: input.id,
title: input.title ?? input.id,
subtitle: input.subtitle ?? "",
changeRequestNumber: input.changeRequestNumber ?? null,
run: () => undefined,
};
}
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
(row) => row.id,
);
}
describe("filterAndRankWorkspaces", () => {
const subject = workspace({
id: "payments",
title: "Payments refactor",
subtitle: "Primary host · feature/checkout",
changeRequestNumber: 42,
});
it("matches everything when the query is empty", () => {
expect(ids([subject], " ")).toEqual(["payments"]);
});
it("matches its own change-request number in every spelling", () => {
const spellings = ["42", " 42 ", "#42", "!42", "pr 42", "mr 42", "PR #42", "mr!42", "pr42"];
for (const query of spellings) {
expect(ids([subject], query)).toEqual(["payments"]);
}
});
it("does not take the number path for text that merely contains digits", () => {
const numbered = workspace({ id: "unrelated workspace", changeRequestNumber: 42 });
for (const query of ["fix-42-retries", "42x", "v4.2", "pr"]) {
expect(ids([numbered], query)).toEqual([]);
}
});
it("does not match a different change-request number that shares digits", () => {
const other = workspace({ id: "unrelated", changeRequestNumber: 142 });
expect(ids([other], "42")).toEqual([]);
});
it("still matches on title and branch text", () => {
expect(ids([subject], "checkout")).toEqual(["payments"]);
expect(ids([subject], "payments")).toEqual(["payments"]);
});
it("leaves a workspace without a change request unaffected", () => {
const noPr = workspace({ id: "docs workspace" });
expect(ids([noPr], "42")).toEqual([]);
expect(ids([noPr], "docs")).toEqual(["docs workspace"]);
});
it("falls back to text matching when the number does not match", () => {
const textual = workspace({
id: "text",
subtitle: "branch fix-42-retries",
changeRequestNumber: 7,
});
expect(ids([textual], "42")).toEqual(["text"]);
});
it("ranks an exact change request above an ordinary text match", () => {
const text = workspace({ id: "a-text", title: "Migration 42" });
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
});
});

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