Compare commits

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

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

* test(desktop): declare packaged smoke archive dependency

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

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

* docs(hub): centralize continuation credential lifecycle

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

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

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

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

* fix(plugin-provider): recover replacement prompts

* test(plugin-provider): await runtime retirement

---------

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

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

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

Closes #4390.

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

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

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

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

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

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-09-09 19:45:24 +02:00
126 changed files with 2655 additions and 4830 deletions
-8
View File
@@ -152,14 +152,6 @@ jobs:
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Install ripgrep (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install ripgrep (Windows)
if: runner.os == 'Windows'
run: choco install ripgrep --yes --no-progress
- name: Build server dependencies
run: npm run build:server-deps
+68 -61
View File
@@ -1,91 +1,98 @@
# Changelog
## 0.8.0-beta.1 - 2026-09-08
## 0.8.0 - 2026-09-10
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
### Plugins
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
**Before upgrading:** desktop now requires macOS 13 or newer. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
### Added
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
### Plugins
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
### Changed
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
### Improved
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
### Fixed
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
## 0.7.2 - 2026-09-02
+7 -2
View File
@@ -106,9 +106,14 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
## Release branches
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
-2
View File
@@ -50,13 +50,11 @@ RUN set -eux; \
ca-certificates \
curl \
git \
ripgrep \
gosu \
lbzip2 \
openssh-client \
procps \
tini; \
rg --version; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
+31 -1
View File
@@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
@@ -64,7 +94,7 @@ release push as the changelog and version commit.
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Added
- Added answer forms for Codex questions asked while the agent continues working
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
sha256-j8U5S71SJoC+5yh7IjkadrRZaHOYeMsi1z8B6K7j4R4=
+28 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37261,12 +37261,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/server": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/server": "0.8.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37513,10 +37513,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37527,7 +37527,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37538,6 +37538,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
@@ -37770,7 +37771,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38666,7 +38667,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38695,14 +38696,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38712,8 +38713,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38735,7 +38736,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38762,7 +38763,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38980,16 +38981,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/highlight": "0.8.0-beta.1",
"@getpaseo/plugin": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/highlight": "0.8.0",
"@getpaseo/plugin": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39867,7 +39868,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-scope").click();
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
@@ -0,0 +1,22 @@
import { test } from "../support/fixtures";
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
for (const launch of ["chat", "terminal", "empty"] as const) {
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave");
});
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
});
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "stay");
});
}
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
});
});
-430
View File
@@ -1,430 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectActiveMatchUncovered(page: Page) {
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
await expect(match).toBeVisible();
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
await expect
.poll(async () => {
const m = await match.boundingBox();
const w = await widget.boundingBox();
if (!m || !w) return "missing";
const covered =
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
return covered ? "covered by Find" : "visible";
})
.toBe("visible");
}
async function findBoxes(page: Page) {
const pane = (await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox())!;
const widget = (await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox())!;
return { pane, widget };
}
async function expectFindInsidePane(page: Page) {
const { pane, widget } = await findBoxes(page);
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
}
async function expectFindAtTopRight(page: Page) {
await expectFindInsidePane(page);
const { pane, widget } = await findBoxes(page);
expect(widget.y).toBeLessThan(pane.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("keeps Find controls inside a narrow source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const right = source(page).filter({ hasText: "needle one" });
await expect(right).toBeVisible();
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
await test.step("stays visible when Replace expands the widget", async () => {
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
});
await test.step("navigation and query focus still work", async () => {
await query(page).press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
});
await query(page).press("Escape");
await expect(right).toBeFocused();
});
});
test("Go to line keeps its dialog with Find closed and open", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
await workspace.navigateTo();
await openSource(page, "lines.txt");
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
await test.step("with Find closed", async () => {
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
await gotoInput.fill("12");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
await expect(gotoInput).toBeHidden();
});
await test.step("with Find open", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("line 3");
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
await gotoInput.fill("30");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
await expect(query(page)).toHaveValue("line 3");
await expect(status(page)).toHaveText("11 matches");
});
});
@@ -1,63 +0,0 @@
import { mkdtemp, rm, symlink } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { test as base, expect } from "../support/fixtures";
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
// PATH construction, which belongs to the CI gate rather than to this journey.
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
const test = base.extend<{}, { searchHostPath: string }>({
searchHostPath: [
async ({ browserName }, provide) => {
void browserName;
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
await symlink(
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
path.join(bin, "git"),
);
await symlink(process.execPath, path.join(bin, "node"));
try {
await provide(bin);
} finally {
await rm(bin, { recursive: true, force: true });
}
},
{ scope: "worker" },
],
e2eDaemonEnvironment: [
async ({ searchHostPath }, provide) => {
await provide({ PATH: searchHostPath });
},
{ scope: "worker" },
],
});
test("missing host ripgrep is actionable and Retry works after installation", async ({
page,
withWorkspace,
searchHostPath,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.keyboard.press("Control+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
await expect(
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
exact: true,
}),
).toBeVisible();
await page
.getByTestId("command-center-panel")
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
await symlink(
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
path.join(searchHostPath, "rg"),
);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
});
@@ -1,46 +0,0 @@
import { writeFile, readFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
test("content-search entry and dismissal preserve live shell input and terminal focus", async ({
page,
}, testInfo) => {
const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" });
try {
const terminal = await harness.createTerminal({ name: "Search shortcut QA" });
await writeFile(
path.join(harness.tempRepo.path, "source.ts"),
'export const greeting = "needle";\n',
);
await harness.openTerminal(page, { terminalId: terminal.id });
await harness.setupPrompt(page);
await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png"));
expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe(
"untouched",
);
} finally {
await harness.cleanup();
}
});
async function searchFromPendingShellCommand(
page: import("@playwright/test").Page,
harness: TerminalE2EHarness,
screenshot: string,
) {
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 });
await page.keyboard.press("Control+Shift+F");
const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(query).toBeFocused();
await query.pressSequentially("needle", { delay: 0 });
await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible();
await page.screenshot({ path: screenshot });
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await page.keyboard.press("Enter");
await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000);
}
@@ -1,195 +0,0 @@
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import {
prepareContentSearch,
searchContents,
previewAndOpenSecondOccurrence,
openCompactPreview,
preserveDirtyBufferAndUndo,
recoverChangedAndMissingPreview,
searchContentsByTouch,
} from "../support/helpers/workspace-content-search";
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await searchContents(page, "needle");
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
await preserveDirtyBufferAndUndo(page, workspace);
});
test("compact search keeps list, preview and Open in the existing popup", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.setViewportSize({ width: 390, height: 844 });
await searchContentsByTouch(page, "needle");
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
});
test("changed saved files, empty results and occurrence limits stay explicit", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await searchContents(page, "needle");
await recoverChangedAndMissingPreview(
page,
workspace,
testInfo.outputPath("content-search-limited.png"),
);
});
test("a host without content search advertises an update requirement without sending the new RPC", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
gate.setWorkspaceContentSearchStripped();
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.keyboard.press("Meta+Shift+F");
await expect(
page.getByText("Update this host to search file contents", { exact: true }),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(
page.getByText("Update this host to search file contents", { exact: true }),
).toBeVisible();
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
await page.keyboard.press("Backspace");
await expect(
page.getByRole("textbox", {
name: "Search commands, files, workspaces, and agents...",
exact: true,
}),
).toBeVisible();
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+p");
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
});
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: "icon.svg",
content: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
imageFirst: true,
},
testInfo.outputPath("svg-source-open.png"),
);
});
test("literal leading-space file identity survives preview and exact tab activation", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: " leading.txt",
content: "needle",
result: " leading.txt:1:1 needle",
cursor: "Line 1, column 7",
},
testInfo.outputPath("literal-path-open.png"),
);
});
test("CR-only saved occurrences use the source editor line and exact selection", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: "cr-only.txt",
content: "first\rneedle",
result: "cr-only.txt:2:1 needle",
cursor: "Line 2, column 7",
},
testInfo.outputPath("cr-source-open.png"),
);
});
import {
openLiteralBackslashFile,
reportSkippedLargeFiles,
} from "../support/helpers/workspace-content-search";
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
await reportSkippedLargeFiles(page, await withWorkspace());
});
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
// other journey in this file with it. Windows separator handling is covered by
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
page,
withWorkspace,
}) => {
test.skip(
process.platform === "win32",
"a file name containing a backslash is not a valid Windows fixture",
);
await openLiteralBackslashFile(page, await withWorkspace());
});
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
test("opening the same occurrence again navigates the pane it is already showing", async ({
page,
withWorkspace,
}) => {
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
});
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
test("a result's tooltip paints the exact path, however deep", async ({
page,
withWorkspace,
}, testInfo) => {
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
await rows.first().hover();
await expect(tip).toBeVisible();
await page.waitForTimeout(400);
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
// Pointing must not move the keyboard selection: Enter still opens the first result.
await page.keyboard.press("Enter");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
});
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
page,
withWorkspace,
}) => {
await keepDesktopOverlayChrome(page, await withWorkspace());
});
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-scope"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -104,20 +104,19 @@ function stripAssistantMessageId(
return JSON.stringify(envelope);
}
function stripServerFeatures(
function stripCanonicalSubmittedPrompts(
message: string | Buffer,
omittedFeatures: Set<string>,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
return message;
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
payload?: { status?: unknown; features?: Record<string, unknown> };
};
const payload = envelope.message?.payload ?? envelope.payload;
if (payload?.status !== "server_info" || !payload.features) return message;
for (const feature of omittedFeatures) delete payload.features[feature];
delete payload.features.canonicalSubmittedPrompts;
return JSON.stringify(envelope);
}
@@ -300,7 +299,7 @@ export async function installDaemonWebSocketGate(page: Page) {
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
const omittedFeatures = new Set<string>();
let stripCanonicalSubmittedPromptsFeature = false;
let shellToolCommandOverride: string | null = null;
let failingTimelineAgentId: string | null = null;
let holdingTimelineAgentId: string | null = null;
@@ -510,7 +509,11 @@ export async function installDaemonWebSocketGate(page: Page) {
serverMessage?.type,
);
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
outboundMessage = stripCanonicalSubmittedPrompts(
outboundMessage,
stripCanonicalSubmittedPromptsFeature,
serverMessage?.type,
);
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
if (isTimelineResponse) {
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
@@ -820,12 +823,8 @@ export async function installDaemonWebSocketGate(page: Page) {
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setWorkspaceContentSearchStripped(): void {
omittedFeatures.add("workspaceContentSearch");
},
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
else omittedFeatures.delete("canonicalSubmittedPrompts");
stripCanonicalSubmittedPromptsFeature = stripped;
},
setShellToolCommandOverride(command: string | null): void {
shellToolCommandOverride = command;
@@ -0,0 +1,197 @@
import { expect, test, type Page } from "../fixtures";
import { readFile } from "node:fs/promises";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
countWorkspaceAgents,
countWorkspaceTerminals,
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
openProjectViaDaemon,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
waitForCreatedWorkspace,
} from "./new-workspace";
import { createTempGitRepo } from "./workspace";
import { expectAppRoute } from "./route-assertions";
import { getServerId } from "./server-id";
import {
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
} from "./new-workspace-launch";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
const PROMPT = "Hello from the navigation guard";
const NEXT_PROMPT = "Keep this newer draft";
const CONTEXT = {
name: "context.json",
mimeType: "application/json",
buffer: Buffer.from('{"preserve":"attachment"}'),
};
const PROFILE: TerminalProfile = {
id: "e2e-nav-guard-echo",
name: "Nav Guard Echo",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
};
export async function verifyDelayedWorkspaceCreation(
page: Page,
launch: "chat" | "terminal" | "empty",
destination: "leave" | "stay" | "new-draft",
isolation: "local" | "worktree" = "local",
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("workspace-focus-");
const profileSeed = await seedTerminalProfiles([PROFILE]);
const delay = await delayBrowserWorkspaceCreatedResponse(page);
const serverId = getServerId();
let localWorkspaceId: string | undefined;
let createdDirectory: string | undefined;
let newDraftRoute = "";
try {
const project = await openProjectViaDaemon(client, repo.path);
localWorkspaceId = project.workspaceId;
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await test.step("Submit workspace creation with the requested content", async () => {
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
if (launch === "chat") {
await dropFileOnComposer(page, CONTEXT);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await submitNewWorkspacePrompt(page, PROMPT);
} else if (launch === "terminal") {
await selectLaunchOption(page, PROFILE.id);
await fillTerminalPrompt(page, PROMPT);
await submitTerminalLaunch(page);
} else {
await submitNewWorkspaceEmpty(page);
}
await delay.waitForCreateRequest();
});
await test.step("Choose where to work while the creation response is held", async () => {
if (destination !== "stay") {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
}
if (destination === "new-draft") {
await openNewWorkspaceComposer(page, project);
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
const url = new URL(page.url());
newDraftRoute = url.pathname + url.search;
}
// Recording pacing only; the response is held deterministically above.
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
delay.release();
});
const created = await waitForCreatedWorkspace(client, knownIds);
createdDirectory = created.workspaceDirectory;
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
// A negative navigation assertion needs an observation window after completion.
await page.waitForTimeout(5000);
const expectedRoute =
destination === "new-draft"
? newDraftRoute
: buildHostWorkspaceRoute(
serverId,
destination === "stay" ? created.id : project.workspaceId,
);
await expectAppRoute(page, expectedRoute);
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
const upload = delay.agentRequests[0]?.attachments?.find(
(item) => item.type === "uploaded_file",
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
expect(delay.agentRequests[0]).toMatchObject({
workspaceId: created.id,
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: created.workspaceDirectory,
},
attachments: [
expect.objectContaining({
fileName: CONTEXT.name,
mimeType: CONTEXT.mimeType,
size: CONTEXT.buffer.length,
}),
],
});
}
});
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
if (launch === "chat") {
await scrollTimelineToOldestLoadedEdge(page);
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
} else if (launch === "terminal") {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
}
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
await openNewWorkspaceComposer(page, project);
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
}
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
});
} catch (error) {
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
console.error(error);
throw error;
} finally {
delay.release();
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
await profileSeed.restore();
await client.close();
await repo.cleanup();
}
}
@@ -1,4 +1,5 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly CreateAgentRequestMessage[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: CreateAgentRequestMessage[] = [];
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request")
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
if (sessionMessage?.type === "workspace.create.request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "workspace.create.response" &&
typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
agentRequests,
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
};
}
type WorkspaceEntry = Awaited<
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
>["entries"][number];
/**
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
* signal that creation finished.
*/
export async function waitForCreatedWorkspace(
client: NewWorkspaceDaemonClient,
knownWorkspaceIds: ReadonlySet<string>,
options?: { timeout?: number },
): Promise<WorkspaceEntry> {
let created: WorkspaceEntry | undefined;
await expect
.poll(
async () => {
const payload = await client.fetchWorkspaces();
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
return created !== undefined;
},
{ timeout: options?.timeout ?? 60_000 },
)
.toBe(true);
if (!created) {
throw new Error("Workspace list reported a new workspace but it could not be read back");
}
return created;
}
export async function countWorkspaceAgents(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
}
export async function countWorkspaceTerminals(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
return payload.terminals.length;
}
@@ -1,347 +0,0 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { composerLocator } from "./composer";
import type { CreatedWorkspace } from "./with-workspace";
export async function prepareContentSearch(workspace: CreatedWorkspace) {
await writeFile(
path.join(workspace.repoPath, "search-a.ts"),
'export const greeting = "NEEDLE";\n',
);
await writeFile(
path.join(workspace.repoPath, "search-b.ts"),
'const emoji = "é🙂 needle needle";\n',
);
await workspace.navigateTo();
}
export async function searchContents(page: Page, query: string) {
await page.keyboard.press("Meta+Shift+F");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(input).toBeVisible();
await input.fill(query);
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
}
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
await page.keyboard.press("ArrowDown");
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
await expect(input).toBeFocused();
// The query field is the Command Center's own header input, above the results and preview.
const inputBox = await input.boundingBox();
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
await panel.screenshot({ path: screenshot });
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
}
export async function openCompactPreview(page: Page, screenshot: string) {
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
await expect(
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
).toContainText("export const greeting");
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
}
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
const source = page.getByTestId("file-source-editor").locator(".cm-content");
await source.fill("const localUnsaved = 42;\n");
await writeFile(
path.join(workspace.repoPath, "search-b.ts"),
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
);
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await searchContents(page, "needle");
await page.keyboard.press("ArrowDown");
await expect(
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
).toContainText("changed on disk");
await page.keyboard.press("Enter");
await expect(source).toContainText("localUnsaved");
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
"saved match is no longer at this location",
);
await source.press("Control+z");
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
await expect(source).not.toContainText("localUnsaved");
}
export async function recoverChangedAndMissingPreview(
page: Page,
workspace: CreatedWorkspace,
screenshot: string,
) {
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
await page.keyboard.press("ArrowDown");
await expect(panel.getByRole("status")).toContainText(
"saved match is no longer at this location",
);
await unlink(path.join(workspace.repoPath, "search-a.ts"));
await page.keyboard.press("ArrowUp");
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await writeFile(
path.join(workspace.repoPath, "search-a.ts"),
'export const greeting = "NEEDLE";\n',
);
await panel.getByRole("button", { name: "Retry", exact: true }).click();
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
await input.fill("not-present-anywhere");
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
await input.fill("needle");
await expect(panel.getByText(/Results limited/)).toBeVisible();
await panel.screenshot({ path: screenshot });
}
export async function searchContentsByTouch(page: Page, query: string) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByRole("button", { name: "Search", exact: true }).click();
await page
.getByRole("textbox", {
name: "Search commands, files, workspaces, and agents...",
exact: true,
})
.fill("Search file contents");
await page.getByText("Search file contents", { exact: true }).click();
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill(query);
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
}
export async function openLiteralSourceOccurrence(
page: Page,
workspace: CreatedWorkspace,
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
screenshot: string,
) {
await writeFile(path.join(workspace.repoPath, input.path), input.content);
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
await workspace.navigateTo();
if (input.imageFirst) {
await page.keyboard.press("Meta+p");
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
await expect(page.getByTestId("image-file-preview")).toBeVisible();
}
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
const source = page.getByTestId("file-source-editor");
await expect(source).toContainText("needle");
await expect(source).not.toContainText("different file");
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
await page.screenshot({ path: screenshot });
}
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
// whole workspace was read.
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await input.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
await expect(
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
).toBeVisible();
// The occurrence-cap notice is a different claim and must not appear here.
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
}
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible();
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
const source = page.getByTestId("file-source-editor");
await expect(source).toContainText("LITERAL FILE");
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
}
/**
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
* file-opening owner to navigate again.
*/
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
const lines = Array.from(
{ length: 1200 },
(_value, index) => `export const filler${index} = ${index};`,
);
lines.push('const marker = "REOPEN_TARGET";');
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
await workspace.navigateTo();
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
const offset = () => scroller.evaluate((element) => element.scrollTop);
await openReopenTarget(page);
const revealed = await offset();
expect(revealed).toBeGreaterThan(0);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// Move the caret to the top of the same file, inside the same editor instance.
await page.getByTestId("file-source-editor").locator(".cm-content").click();
await page.keyboard.press("ControlOrMeta+Home");
await expect.poll(offset).toBeLessThan(revealed / 2);
await openReopenTarget(page);
await expect
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
.toBeGreaterThan(revealed / 2);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// The editor instance is reused, so its history survives the second navigation.
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
}
async function openReopenTarget(page: Page) {
await page.keyboard.press("Meta+Shift+F");
const panel = page.getByTestId("command-center-panel");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("REOPEN_TARGET");
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
}
/**
* Two files can share a base name and their nearest parent, and a real path is often wider than
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
* is not enough: the tooltip has to be the thing painted at its own coordinates.
*/
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
const deep =
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
for (const relative of [
"packages/app/src/utils/index.ts",
"packages/server/src/utils/index.ts",
deep,
]) {
const target = path.join(workspace.repoPath, relative);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
}
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("SHARED_UTIL");
const panel = page.getByTestId("command-center-panel");
const rows = panel.getByRole("button", { name: /:1:24/ });
await expect(rows).toHaveCount(3);
const tip = page.getByTestId("content-search-row-path");
async function expectPaintedPath(row: Locator, expected: string) {
await row.hover();
await expect(tip).toHaveText(expected);
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
// to be the tooltip, not the panel that used to cover it.
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
// way, and its surface is the same white as the panel. What settles it is whether the pixels
// in its own rectangle change when it opens: if the panel still covers it, they do not.
const box = await tip.boundingBox();
if (!box) throw new Error("tooltip has no box");
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
const shown = await page.screenshot({ clip });
await page.mouse.move(2, 2);
await expect(tip).toBeHidden();
const hidden = await page.screenshot({ clip });
expect(
shown.equals(hidden),
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
).toBe(false);
}
// Same base name and same nearest parent: the resting labels cannot tell these apart.
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
// A path far wider than the result column still reads in full.
await expectPaintedPath(
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
`${deep}:1:24`,
);
return { panel, rows, tip };
}
/**
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
* rather than a native Modal must not change any of it.
*/
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
await workspace.navigateTo();
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
// Backdrop dismisses the panel.
const composer = composerLocator(page);
await composer.click();
await expect(composer).toBeFocused();
await page.keyboard.press("Meta+Shift+F");
await expect(input).toBeFocused();
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
await expect(panel).toBeHidden();
// Escape dismisses it and hands focus back to the composer it was opened from.
await page.keyboard.press("Meta+Shift+F");
await input.fill("CHROME_TARGET");
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
await expect(composer).toBeFocused();
// Ordinary commands and the Files scope still open over the same chrome.
await page.keyboard.press("Meta+k");
await expect(page.getByTestId("command-center-input")).toBeFocused();
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+p");
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,4 +1,3 @@
import { WorkspaceContentSearch } from "./workspace-content-search";
import {
FlatList,
Modal,
@@ -11,8 +10,7 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -38,7 +36,6 @@ import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
getOverlayRoot,
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
@@ -93,18 +90,7 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const SCOPE_LABELS = {
files: {
chip: "shell.commandCenter.files",
placeholder: "shell.commandCenter.filePlaceholder",
},
content: {
chip: "shell.commandCenter.content",
placeholder: "shell.commandCenter.contentPlaceholder",
},
} as const;
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
@@ -273,7 +259,7 @@ function useCommandCenterState(): CommandCenterState {
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
@@ -598,7 +584,6 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
@@ -715,30 +700,18 @@ export function CommandCenter() {
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
// Every scope wears the same header; only the chip and the placeholder name it.
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
// can never disagree about who is steering.
const dispatchKey = useCallback(
(key: string) =>
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
[dispatchKey],
);
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!dispatchKey(event.key)) return false;
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[dispatchKey],
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
@@ -757,9 +730,7 @@ export function CommandCenter() {
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
@@ -773,7 +744,9 @@ export function CommandCenter() {
accessible={false}
>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -782,7 +755,11 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={placeholder}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
@@ -793,89 +770,48 @@ export function CommandCenter() {
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</>
)}
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
const desktopContent = (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View
ref={setWebOverlayScope}
testID="command-center-panel"
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? (
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</>
)}
</View>
</View>
</OverlayLayerProvider>
);
return hostDesktopOverlay(desktopContent, state.close);
}
/**
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
* on a result row — can never cover the panel however high its own z-index, so the row could not
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
* docs/floating-panels.md. Native keeps the Modal.
*/
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
if (isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
return (
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
{content}
</Modal>
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
</View>
</Modal>
</OverlayLayerProvider>
);
}
@@ -905,7 +841,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-scope"
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
@@ -916,14 +852,12 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
pointerEvents: "auto",
},
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
panel: {
width: 640,
height: 560,
@@ -1,513 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
import { isWeb } from "@/constants/platform";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { WorkspaceFileSource } from "@/file-pane/source";
import { formatFileSize } from "@/utils/format-file-size";
import { describeResultPath } from "./internal/result-path";
import {
WorkspaceContentSearchModel,
type ContentSearchSnapshot,
type ContentSearchTransport,
} from "./internal/model";
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface Props {
/** The Command Center owns the field; this body only reacts to what was typed in it. */
query: string;
compact: boolean;
close(): void;
clearScope(): void;
keyHandler: React.RefObject<((key: string) => boolean) | null>;
}
export function WorkspaceContentSearch(props: Props) {
const { keyHandler, close, clearScope } = props;
const { t } = useTranslation();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
const unavailable =
!session?.client ||
!cwd ||
!serverId ||
!workspaceId ||
!session.serverInfo?.features?.workspaceContentSearch;
useEffect(() => {
if (!unavailable) return;
keyHandler.current = (key) => {
if (key === "Escape") close();
else if (key === "Backspace") clearScope();
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
};
return () => {
keyHandler.current = null;
};
}, [unavailable, keyHandler, close, clearScope]);
if (!session?.client || !cwd || !serverId || !workspaceId)
return <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
if (!session.serverInfo?.features?.workspaceContentSearch)
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
return (
<ContentSearch
key={`${serverId}:${workspaceId}`}
{...props}
transport={session.client}
cwd={cwd}
serverId={serverId}
workspaceId={workspaceId}
/>
);
}
function ContentSearch({
transport,
cwd,
serverId,
workspaceId,
query,
compact,
close,
clearScope,
keyHandler,
}: Props & {
transport: ContentSearchTransport;
cwd: string;
serverId: string;
workspaceId: string;
}) {
const [previewPage, setPreviewPage] = useState(false);
const closeRef = useRef(close);
closeRef.current = close;
const model = useMemo(
() =>
new WorkspaceContentSearchModel({
cwd,
transport,
open(location) {
clearCommandCenterFocusRestoreElement();
const layout = useWorkspaceLayoutStore.getState();
openWorkspaceFileInFocusedPane({
location,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
focusWorkspaceTab: layout.focusTab,
requestFileNavigation: layout.requestFileNavigation,
});
closeRef.current();
},
}),
[cwd, transport, serverId, workspaceId],
);
useEffect(() => () => model.dispose(), [model]);
// The field lives in the Command Center header, so the typed value is a prop; this is the only
// bridge from it into the model that owns the search.
useEffect(() => {
setPreviewPage(false);
model.setQuery(query);
}, [model, query]);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const listRef = useRef<FlatList>(null);
useEffect(() => {
if (state.matches.length && (!compact || !previewPage))
listRef.current?.scrollToIndex({
index: state.activeIndex,
animated: false,
viewPosition: 0.5,
});
}, [compact, previewPage, state.activeIndex, state.matches]);
const key = useCallback(
(pressed: string) => {
if (pressed === "Escape") {
close();
return true;
}
if (pressed === "Backspace" && !state.query) {
clearScope();
return true;
}
if (pressed === "ArrowDown" || pressed === "ArrowUp") {
model.move(pressed === "ArrowDown" ? 1 : -1);
return true;
}
if (pressed === "Enter") {
model.openSelected();
return true;
}
return false;
},
[close, clearScope, model, state.query],
);
useEffect(() => {
keyHandler.current = key;
return () => {
if (keyHandler.current === key) keyHandler.current = null;
};
}, [keyHandler, key]);
const select = useCallback(
(index: number) => {
model.select(index);
if (compact) setPreviewPage(true);
},
[model, compact],
);
const back = useCallback(() => setPreviewPage(false), []);
const renderItem = useCallback(
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
),
[select, state.activeIndex],
);
const emptyState = useMemo(
() => <SearchState state={state} retry={model.retry} />,
[model.retry, state],
);
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
return (
<View style={styles.body}>
{!compact || !previewPage ? (
<View style={[styles.results, compact && styles.full]}>
<FlatList
ref={listRef}
data={state.matches}
keyboardShouldPersistTaps="handled"
keyExtractor={resultKey}
getItemLayout={resultLayout}
renderItem={renderItem}
ListEmptyComponent={emptyState}
ListFooterComponent={limitNotice}
/>
</View>
) : null}
{!compact || previewPage ? (
<ContentPreview state={state} model={model} compact={compact} back={back} />
) : null}
</View>
);
}
function ResultRow({
item,
index,
selected,
select,
}: {
item: WorkspaceContentMatch;
index: number;
selected: boolean;
select(index: number): void;
}) {
const onPress = useCallback(() => select(index), [index, select]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
// keyboard cursor that drives the preview stays distinguishable from the pointer.
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed) && styles.hovered,
selected && styles.selected,
],
[selected],
);
const line = useMemo(() => describeMatchLine(item), [item]);
const file = useMemo(() => describeResultPath(item.path), [item.path]);
const location = `${item.path}:${item.line}:${item.columnStart}`;
return (
// The resting row shows the file and its nearest parent, which two files can share
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
// where the exact workspace-relative path lives; it is not bounded by the column's width.
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${location} ${item.snippet}`}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? selected : undefined}
onPress={onPress}
style={style}
>
<Text style={styles.snippet} numberOfLines={1}>
{line.before}
<Text style={styles.match}>{line.match}</Text>
{line.after}
</Text>
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
path loses its middle rather than the identity the reader is scanning for. */}
<View style={styles.location}>
{file.directory ? (
<Text style={styles.locationDirectory} numberOfLines={1}>
{file.directory}
</Text>
) : null}
<Text style={styles.locationFile} numberOfLines={1}>
{file.name}:{item.line}:{item.columnStart}
</Text>
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
<Text style={styles.tooltipPath}>{location}</Text>
</TooltipContent>
</Tooltip>
);
}
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
function describeMatchLine(item: WorkspaceContentMatch) {
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
const before = item.snippet.slice(from, item.snippetMatchStart);
return {
before: cut ? `…${before}` : before.trimStart(),
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
after: item.snippet.slice(item.snippetMatchEnd),
};
}
function resultKey(item: WorkspaceContentMatch) {
return `${item.path}:${item.line}:${item.columnStart}`;
}
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
}
/** Idle stays blank: the header placeholder already says what the field does. */
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
const { t } = useTranslation();
if (state.status === "searching")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<ThemedSpinner size={14} />
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
</View>
);
if (state.status === "error")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
<Button variant="outline" size="sm" onPress={retry}>
{t("common.actions.retry")}
</Button>
</View>
);
if (state.status === "ready")
return (
<View style={styles.empty}>
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
are never opened, so the empty state has to say so where the claim is made. */}
{state.maxFileBytes ? (
<Text style={styles.emptyDetail}>
{t("shell.commandCenter.contentSkippedLargeFiles", {
size: formatFileSize(state.maxFileBytes),
})}
</Text>
) : null}
</View>
);
return null;
}
function ResultLimit({ limited }: { limited: boolean }) {
const { t } = useTranslation();
if (!limited) return null;
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
}
function ContentPreview({
state,
model,
compact,
back,
}: {
state: ContentSearchSnapshot;
model: WorkspaceContentSearchModel;
compact: boolean;
back(): void;
}) {
const { t } = useTranslation();
const match = state.matches[state.activeIndex];
const preview = state.preview;
const location = useMemo(
() =>
match
? {
path: match.path,
lineStart: match.line,
columnStart: match.columnStart,
columnEnd: match.columnEnd,
expectedText: match.text,
}
: null,
[match],
);
return (
<View style={styles.preview}>
{/* On desktop the selected row already names the file and Enter opens it, so the preview
carries no header. The compact flow pages away from the list and still needs both. */}
{compact && match ? (
<View style={styles.previewHeader}>
<Button variant="ghost" size="sm" onPress={back}>
{t("shell.commandCenter.contentBack")}
</Button>
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
{match.path}
</Text>
<Button variant="outline" size="sm" onPress={model.openSelected}>
{t("shell.commandCenter.contentOpen")}
</Button>
</View>
) : null}
{preview.status === "loading" ? (
<View
style={styles.status}
accessibilityRole="progressbar"
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
>
<ThemedSpinner size={14} />
</View>
) : null}
{preview.status === "error" ? (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
<Button variant="outline" size="sm" onPress={model.retryPreview}>
{t("common.actions.retry")}
</Button>
</View>
) : null}
{preview.status === "ready" && location ? (
<WorkspaceFileSource
content={preview.content}
filename={preview.path}
size={preview.size}
navigationRevision={state.activeIndex}
location={location}
/>
) : null}
</View>
);
}
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
// search wants as many occurrences on screen as stay legible.
const RESULT_ROW_HEIGHT = 44;
// Characters of the matched line kept ahead of the match itself.
const SNIPPET_LEAD = 24;
const styles = StyleSheet.create((theme) => ({
body: { flex: 1, minHeight: 0, flexDirection: "row" },
// A result row is two truncated single lines, so extra panel width buys the list nothing and
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
// desktop panel cannot starve the preview.
results: {
width: 360,
maxWidth: "45%",
minHeight: 0,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
row: {
height: RESULT_ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[1],
},
hovered: { backgroundColor: theme.colors.surface1 },
selected: { backgroundColor: theme.colors.surface2 },
// Three levels: the match is what the reader is looking for, the rest of the line is the
// context it sits in, and the location is context for that.
snippet: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.code,
lineHeight: 17,
fontFamily: theme.fontFamily.mono,
},
match: {
color: theme.colors.foreground,
fontWeight: "600",
backgroundColor: theme.colors.terminal.selectionBackground,
},
location: { flexDirection: "row", alignItems: "baseline" },
locationFile: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
locationDirectory: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
preview: { flex: 1, minWidth: 0, minHeight: 0 },
previewHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
status: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
},
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
error: { color: theme.colors.statusDanger },
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
empty: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
gap: theme.spacing[1],
},
emptyText: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
emptyDetail: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
limit: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,234 +0,0 @@
import { expect, test } from "vitest";
import type { FileReadResult } from "@getpaseo/client";
import type {
WorkspaceContentMatch,
WorkspaceContentSearchResult,
} from "@getpaseo/protocol/messages";
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function match(path: string, text = "NEEDLE"): WorkspaceContentMatch {
return {
path,
line: 1,
columnStart: 1,
columnEnd: text.length + 1,
text,
snippet: text,
snippetMatchStart: 0,
snippetMatchEnd: text.length,
};
}
function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult {
return { status: "ok", matches, limited: false };
}
function file(path: string, text: string): FileReadResult {
return {
path,
kind: "text",
mime: "text/plain",
bytes: new TextEncoder().encode(text),
size: text.length,
modifiedAt: "2026-09-09",
};
}
test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => {
const first = deferred<WorkspaceContentSearchResult>();
const second = deferred<WorkspaceContentSearchResult>();
const startedFirst = deferred<void>();
const startedSecond = deferred<void>();
const signals: AbortSignal[] = [];
const opened: unknown[] = [];
const transport: ContentSearchTransport = {
searchWorkspaceContent: ({ query }, { signal }) => {
signals.push(signal);
if (query === "old") {
startedFirst.resolve();
return first.promise;
}
startedSecond.resolve();
return second.promise;
},
readFile: async (path) => file(path, "NEEDLE"),
};
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
transport,
open: (location) => opened.push(location),
debounceMs: 0,
});
try {
model.setQuery("old");
await startedFirst.promise;
model.setQuery("needle");
await startedSecond.promise;
expect(signals[0].aborted).toBe(true);
second.resolve(result([match("a.ts")]));
await Promise.resolve();
await Promise.resolve();
first.resolve(result([match("obsolete.ts")]));
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({
query: "needle",
status: "ready",
matches: [match("a.ts")],
preview: { status: "ready", content: "NEEDLE" },
});
model.openSelected();
expect(opened).toEqual([
{ path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" },
]);
} finally {
model.dispose();
}
});
test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => {
const started = deferred<void>();
const searched = deferred<WorkspaceContentSearchResult>();
const a = deferred<FileReadResult>();
const c = deferred<FileReadResult>();
const paths: string[] = [];
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: () => {
started.resolve();
return searched.promise;
},
readFile: (_cwd, path) => {
paths.push(path);
return path === "a" ? a.promise : c.promise;
},
},
});
try {
model.setQuery("needle");
await started.promise;
searched.resolve(result([match("a"), match("b"), match("c")]));
await Promise.resolve();
model.select(1);
model.select(2);
expect(paths).toEqual(["a"]);
a.resolve(file("a", "NEEDLE"));
await Promise.resolve();
expect(paths).toEqual(["a", "c"]);
c.resolve(file("c", "changed"));
await Promise.resolve();
expect(model.getSnapshot().preview).toMatchObject({
status: "ready",
path: "c",
content: "changed",
});
} finally {
model.dispose();
}
});
test("a new query refreshes the saved preview even when its selected path is unchanged", async () => {
const reads: Array<ReturnType<typeof deferred<FileReadResult>>> = [];
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: () => {
const pending = deferred<WorkspaceContentSearchResult>();
searches.push(pending);
return pending.promise;
},
readFile: () => {
const pending = deferred<FileReadResult>();
reads.push(pending);
return pending.promise;
},
},
});
try {
model.setQuery("first");
await expect.poll(() => searches.length).toBe(1);
searches[0].resolve(result([match("same.ts")]));
await Promise.resolve();
model.setQuery("second");
await expect.poll(() => searches.length).toBe(2);
searches[1].resolve(result([match("same.ts")]));
await Promise.resolve();
reads[0].resolve(file("same.ts", "old saved text"));
await Promise.resolve();
expect(reads).toHaveLength(2);
reads[1].resolve(file("same.ts", "NEEDLE current saved text"));
await Promise.resolve();
expect(model.getSnapshot().preview).toMatchObject({
status: "ready",
content: "NEEDLE current saved text",
});
} finally {
model.dispose();
}
});
test("textual image occurrences expose saved source without changing the read kind", async () => {
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: async () => result([match("icon.svg")]),
readFile: async () => ({
...file("icon.svg", "<svg>NEEDLE</svg>"),
kind: "image",
mime: "image/svg+xml",
}),
},
});
try {
model.setQuery("needle");
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
expect(model.getSnapshot().preview).toMatchObject({ content: "<svg>NEEDLE</svg>" });
} finally {
model.dispose();
}
});
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
const reads: string[] = [];
let deleted = false;
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
readFile: async (_cwd, path) => {
reads.push(path);
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
return file(path, `NEEDLE in ${path}`);
},
},
});
try {
model.setQuery("needle");
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
expect(reads).toEqual(["a.ts"]);
model.move(1);
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
deleted = true;
model.move(-1);
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
} finally {
model.dispose();
}
});
@@ -1,206 +0,0 @@
import type {
WorkspaceContentMatch,
WorkspaceContentSearchResult,
} from "@getpaseo/protocol/messages";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { FileReadResult } from "@getpaseo/client";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
export interface ContentSearchTransport {
searchWorkspaceContent(
input: { cwd: string; query: string },
options: { signal: AbortSignal },
): Promise<WorkspaceContentSearchResult>;
readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
): Promise<FileReadResult>;
}
type Preview =
| { status: "empty" | "loading" }
| { status: "error"; message: string }
| { status: "ready"; path: string; content: string; size: number };
export interface ContentSearchSnapshot {
query: string;
status: "idle" | "searching" | "ready" | "error";
message: string;
matches: WorkspaceContentMatch[];
limited: boolean;
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
maxFileBytes: number | null;
activeIndex: number;
preview: Preview;
}
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
const MAX_PREVIEW_BYTES = 1_048_576;
export class WorkspaceContentSearchModel {
private snapshot: ContentSearchSnapshot = {
query: "",
status: "idle",
message: "",
matches: [],
limited: false,
maxFileBytes: null,
activeIndex: 0,
preview: { status: "empty" },
};
private listeners = new Set<() => void>();
private controller: AbortController | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
private reading = false;
private file: { path: string; content: string; size: number } | null = null;
constructor(
private readonly options: {
cwd: string;
transport: ContentSearchTransport;
open(location: WorkspaceFileLocation): void;
debounceMs?: number;
},
) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = () => this.snapshot;
private update(next: Partial<ContentSearchSnapshot>) {
this.snapshot = { ...this.snapshot, ...next };
for (const listener of this.listeners) listener();
}
setQuery = (query: string) => {
this.controller?.abort();
this.file = null;
clearTimeout(this.timer);
this.update({
query,
status: query ? "searching" : "idle",
message: "",
matches: [],
limited: false,
maxFileBytes: null,
activeIndex: 0,
preview: { status: "empty" },
});
if (!query || this.disposed) return;
const controller = new AbortController();
this.controller = controller;
this.timer = setTimeout(
() => void this.search(query, controller),
this.options.debounceMs ?? 100,
);
};
private async search(query: string, controller: AbortController) {
try {
const result = await this.options.transport.searchWorkspaceContent(
{ cwd: this.options.cwd, query },
{ signal: controller.signal },
);
if (this.disposed || controller.signal.aborted) return;
if (result.status === "error") {
this.update({ status: "error", message: result.message });
return;
}
this.update({
status: "ready",
matches: result.matches,
limited: result.limited,
maxFileBytes: result.maxFileBytes ?? null,
});
this.select(0);
} catch (error) {
if (!this.disposed && !controller.signal.aborted)
this.update({
status: "error",
message: error instanceof Error ? error.message : "File search failed",
});
}
}
select = (index: number) => {
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
this.update({ activeIndex });
void this.preview();
};
move = (direction: number) =>
this.select(
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
(this.snapshot.matches.length || 1),
);
private async preview() {
const match = this.snapshot.matches[this.snapshot.activeIndex];
if (!match) return;
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
// tell the truth about a file that changed or disappeared since the search ran; see the
// deleted-file recovery case in the browser spec before trading it for fewer reads.
if (this.file?.path === match.path) {
this.update({ preview: { status: "ready", ...this.file } });
return;
}
this.update({ preview: { status: "loading" } });
if (this.reading) return;
this.reading = true;
const path = match.path;
const query = this.controller;
try {
const file = await this.options.transport.readFile(
this.options.cwd,
path,
undefined,
MAX_PREVIEW_BYTES,
);
if (this.disposed) return;
if (query === this.controller) {
const content = explorerFileFromReadResult(file).content;
if (content === undefined) throw new Error("This file is no longer text");
this.file = { path, content, size: file.size };
}
} catch (error) {
if (
!this.disposed &&
query === this.controller &&
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
) {
this.update({
preview: {
status: "error",
message: error instanceof Error ? error.message : "Preview unavailable",
},
});
return;
}
} finally {
this.reading = false;
}
if (!this.disposed) void this.preview();
}
retry = () => {
this.file = null;
this.setQuery(this.snapshot.query);
};
retryPreview = () => {
this.file = null;
void this.preview();
};
openSelected = () => {
const match = this.snapshot.matches[this.snapshot.activeIndex];
if (!match || this.snapshot.status !== "ready") return;
this.options.open({
path: match.path,
lineStart: match.line,
columnStart: match.columnStart,
columnEnd: match.columnEnd,
expectedText: match.text,
});
};
dispose = () => {
this.disposed = true;
this.controller?.abort();
clearTimeout(this.timer);
this.listeners.clear();
this.file = null;
};
}
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { describeResultPath } from "./result-path";
describe("describeResultPath", () => {
it("keeps a root file whole", () => {
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
});
it("keeps a shallow path whole", () => {
expect(describeResultPath("docs/architecture.md")).toEqual({
directory: "docs/",
name: "architecture.md",
});
});
it("elides the head of a deep path and keeps the distinguishing parent", () => {
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
expect(
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
directory: "…/source/",
name: "index.tsx",
});
});
it("keeps a literal backslash in a file name out of the directory", () => {
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
});
});
@@ -1,25 +0,0 @@
/**
* How a result row names its file.
*
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
* reader nothing. The nearest directories are what distinguish them, and they are also what an
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
* and marks the elided head with a leading ellipsis.
*/
export interface ResultPathParts {
/** Directory context immediately above the file, already elided at the head when deeper. */
directory: string;
/** File name, which is never truncated away. */
name: string;
}
const PARENTS_KEPT = 1;
export function describeResultPath(path: string): ResultPathParts {
const segments = path.split("/");
const name = segments.pop() ?? path;
if (segments.length === 0) return { directory: "", name };
const kept = segments.slice(-PARENTS_KEPT);
const elided = segments.length > kept.length;
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
}
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
directory: string;
}
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
const separator = path.lastIndexOf("/");
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return {
path,
name: separator >= 0 ? path.slice(separator + 1) : path,
directory: separator >= 0 ? path.slice(0, separator) : "",
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
};
}
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
});
});
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
path: "a\\b.txt",
name: "a\\b.txt",
directory: "",
it("normalizes Windows separators before opening or presenting a file", () => {
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
(path: string) => {
if (!serverId || !workspaceId) return;
clearCommandCenterFocusRestoreElement();
openWorkspaceFileInFocusedPane({
location: { path },
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
@@ -141,7 +141,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
});
},
[serverId, workspaceId],
@@ -1,4 +1,3 @@
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -328,30 +327,10 @@ export function useWorkspaceCommandCenterActions(): void {
],
);
const allActions = useMemo(
() => [
...actions,
{
id: "workspace-content-search",
group: "actions" as const,
groupRank: 0,
rank: 10,
visibility: "query" as const,
keywords: ["search", "find", "contents", "files", "text"],
run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"),
presentation: {
kind: "action" as const,
title: t("shell.commandCenter.contentTitle"),
sectionTitle: t("shell.commandCenter.actions"),
},
},
],
[actions, t],
);
useCommandCenterActions({
sourceId: "workspace",
enabled: Boolean(serverId && cwd),
actions: allActions,
actions,
});
}
@@ -14,7 +14,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
@@ -119,7 +119,6 @@ export function CompactExplorerSidebarHost({
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
@@ -137,24 +136,16 @@ export function CompactExplorerSidebarHost({
if (!model) {
return;
}
openWorkspaceFileInFocusedPane({
location: { path: filePath },
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
requestFileNavigation,
});
},
[
focusWorkspaceTab,
model,
openWorkspaceTabInFocusedPane,
presentation,
requestFileNavigation,
showMobileAgent,
],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
@@ -80,8 +80,11 @@ function QuestionOptionRow({
);
const optionLabelStyle = useMemo(
() => [styles.optionLabel, { color: theme.colors.foreground }],
[theme.colors.foreground],
() => [
styles.optionLabel,
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
);
const optionDescriptionStyle = useMemo(
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
@@ -96,11 +99,11 @@ function QuestionOptionRow({
styles.selectionControl,
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
{
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted,
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
},
],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted],
);
const radioDotStyle = useMemo(
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
@@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({
questionText: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
lineHeight: 22,
},
optionsWrap: {
@@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({
},
questionNavText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
optionItem: {
flexDirection: "row",
@@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({
},
optionLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
fontWeight: theme.fontWeight.normal,
lineHeight: 22,
},
optionDescription: {
@@ -0,0 +1,35 @@
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { encodeImages } from "@/utils/encode-images";
import type { UserMessageImageAttachment } from "@/types/stream";
export interface WorkspaceDraftAgentRequest {
workspaceId: string;
config: AgentSessionConfig;
text: string;
clientMessageId: string;
images?: UserMessageImageAttachment[];
attachments?: CreateAgentRequestMessage["attachments"];
}
/**
* Shared by the workspace draft tab and by the new-workspace screen when it finishes creation
* after the user has already navigated away and no draft tab will ever mount.
*/
export async function requestWorkspaceDraftAgent(
client: DaemonClient,
request: WorkspaceDraftAgentRequest,
): Promise<AgentSnapshotPayload> {
const images = await encodeImages(request.images);
return await client.createAgent({
config: request.config,
workspaceId: request.workspaceId,
clientMessageId: request.clientMessageId,
...(request.text ? { initialPrompt: request.text } : {}),
...(images && images.length > 0 ? { images } : {}),
...(request.attachments && request.attachments.length > 0
? { attachments: request.attachments }
: {}),
});
}
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { encodeImages } from "@/utils/encode-images";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({
const result = await requestWorkspaceDraftAgent(client, {
config,
workspaceId,
...(text ? { initialPrompt: text } : {}),
text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
return {
@@ -72,9 +72,3 @@ describe("resolveFilePreviewReadTarget", () => {
).toBeNull();
});
});
it("preserves literal whitespace for a source read target", () => {
expect(
resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }),
).toEqual({ cwd: "/workspace ", path: " leading.txt " });
});
@@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: {
path: string;
workspaceRoot?: string;
}): FilePreviewReadTarget | null {
const previewPath = input.path;
const previewPath = input.path.trim();
if (!previewPath) {
return null;
}
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
};
}
const workspaceRoot = input.workspaceRoot;
const workspaceRoot = input.workspaceRoot?.trim();
if (!isAbsolutePath(previewPath)) {
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
@@ -32,20 +32,3 @@ describe("explorerFileFromReadResult", () => {
expect(embedded.hasBom).toBe(false);
});
});
it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => {
const file = {
...textRead(new TextEncoder().encode("<svg>needle</svg>")),
kind: "image" as const,
mime: "image/svg+xml",
path: "icon.svg",
};
expect(explorerFileFromReadResult(file)).toMatchObject({
kind: "image",
content: "<svg>needle</svg>",
});
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
kind: "image",
content: undefined,
});
});
+2 -11
View File
@@ -2,21 +2,12 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
// SVG keeps its normal image presentation while exposing its UTF-8 source to line targets.
const isText = file.kind === "text" || file.mime === "image/svg+xml";
let content: string | undefined;
if (isText) {
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
} catch (error) {
if (file.kind === "text") throw error;
}
}
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content,
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
+6 -1
View File
@@ -7,7 +7,6 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -116,6 +115,12 @@ export function FilePanelBar({
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
@@ -5,6 +5,7 @@ import {
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
@@ -41,6 +42,7 @@ export function editorBaseExtensions(onSave: () => void) {
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
@@ -61,13 +63,6 @@ export function editorTheme(theme: EditorVisualTheme) {
fontFamily: theme.monoFont,
lineHeight: "1.45",
},
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
".cm-panels-top": { borderBottom: "none" },
".cm-searchMatch": { backgroundColor: theme.selection },
".cm-searchMatch-selected": {
backgroundColor: theme.selection,
outline: `1px solid ${theme.cursor}`,
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
+16 -36
View File
@@ -1,12 +1,10 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -40,8 +38,6 @@ export function FileEditorView({
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
@@ -58,7 +54,6 @@ export function FileEditorView({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
find.extension,
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
wrappingCompartment.of(wrappingForFile(values.filename)),
@@ -86,7 +81,7 @@ export function FileEditorView({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -104,12 +99,15 @@ export function FileEditorView({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
const from = view.state.doc.line(lineStart).from;
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
view.dispatch({
selection: { anchor: from, head: to },
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}, [location, navigationRevision]);
}, [location.lineEnd, location.lineStart, navigationRevision]);
useEffect(() => {
viewRef.current?.dispatch({
@@ -142,34 +140,16 @@ export function FileEditorView({
return () => cm.off("vim-mode-change", handleModeChange);
}, [onVimModeChange, vimEnabled]);
const changed = resolveWorkspaceFileSelection(
snapshot.content.replace(/\r\n?/g, "\n"),
location,
).changed;
return (
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<FileFind model={find} editor={viewRef} />
</div>
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
const remoteUpdate = Annotation.define<boolean>();
// The Find widget floats inside this frame, so it stays relative; the change notice is the
// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
@@ -1,117 +0,0 @@
import {
useCallback,
useMemo,
useEffect,
useRef,
useSyncExternalStore,
type RefObject,
} from "react";
import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { usePaneFocusIfAny } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { FileFindModel } from "./model.web";
export { FileFindModel } from "./model.web";
export function FileFind({
model,
editor,
}: {
model: FileFindModel;
editor: RefObject<EditorView | null>;
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const widget = useRef<PaneFindHandle>(null);
// A source view can also render outside any pane, such as the Command Center content
// preview. Pane Find belongs to the pane, so there is nothing to open there.
const pane = usePaneFocusIfAny();
const active = useRetainedPanelActive();
// RN Web hands the underlying DOM element to a View ref; the model measures it.
const setWidgetNode = useCallback(
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
[model],
);
useEffect(() => {
if (!pane?.isInteractive || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, editor, model, pane?.isInteractive]);
const replace = useMemo(
() =>
state.readOnly
? undefined
: {
value: state.replacement,
onChange: model.setReplacement,
onReplace: model.replace,
onReplaceAll: model.replaceAll,
},
[model, state.readOnly, state.replacement],
);
if (!pane || !state.open) return null;
const total = `${state.total}${state.limited ? "+" : ""}`;
let status = "";
if (state.query) {
if (state.total === 0) status = t("paneFind.noMatches");
else if (state.current) status = t("paneFind.position", { current: state.current, total });
else status = t("paneFind.total", { total });
}
return (
<View
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
pointerEvents="box-none"
>
<View ref={setWidgetNode} style={styles.widget}>
<PaneFind
ref={widget}
query={state.query}
status={status}
canNavigate={state.total > 0}
onQueryChange={model.setSearch}
onNext={model.next}
onPrevious={model.previous}
onClose={model.close}
replace={replace}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The widget floats over the content; the model flips the corner when it would
// otherwise sit on top of the active match.
overlay: {
position: "absolute",
left: theme.spacing[2],
right: theme.spacing[2],
alignItems: "flex-end",
zIndex: 1,
},
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
widget: { maxWidth: "100%" },
overlayTop: { top: theme.spacing[2] },
overlayBottom: { bottom: theme.spacing[2] },
}));
@@ -1,255 +0,0 @@
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
import {
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
openSearchPanel,
replaceAll,
replaceNext,
search,
searchKeymap,
SearchQuery,
setSearchQuery,
} from "@codemirror/search";
const MATCH_STATUS_LIMIT = 10_000;
/** Which corner of the editor the floating Find widget occupies. */
export type FindPlacement = "top" | "bottom";
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
export class FileFindModel {
private view: EditorView | null = null;
private listeners = new Set<() => void>();
private matches: Array<{ from: number; to: number }> = [];
private widget: HTMLElement | null = null;
private widgetResize: ResizeObserver | null = null;
private snapshot = {
open: false,
placement: "top" as FindPlacement,
query: "",
replacement: "",
current: 0,
total: 0,
limited: false,
readOnly: true,
};
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
readonly getSnapshot = () => this.snapshot;
readonly extension: Extension = [
// The widget floats over the editor as a sibling overlay, so this panel is an
// inert placeholder that only reports whether Find is open. Hide the placeholder
// itself, never the shared panel container — Go to line's dialog lives there too.
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
// Reveal matches clear of the widget instead of underneath it.
EditorView.scrollMargins.of((view) => {
const clearance = this.clearance(view);
if (!clearance) return null;
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
}),
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
EditorState.transactionExtender.of((transaction) => {
// CodeMirror seeds every search-opening command from the selection. A
// single-line field cannot represent CR/LF, so keep the previous query.
const multilineSeed = transaction.effects.some(
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
);
if (!multilineSeed) return null;
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
}),
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
];
readonly open = (view = this.view) => {
if (view) openSearchPanel(view);
};
readonly close = () => {
if (this.view) {
closeSearchPanel(this.view);
this.view.focus();
}
};
readonly next = () => {
if (this.view) findNext(this.view);
};
readonly previous = () => {
if (this.view) findPrevious(this.view);
};
readonly replace = () => {
if (this.view) replaceNext(this.view);
};
readonly replaceAll = () => {
if (this.view) replaceAll(this.view);
};
readonly setReplacement = (replacement: string) =>
this.setQuery(this.snapshot.query, replacement);
readonly setSearch = (query: string) => {
this.setQuery(query, this.snapshot.replacement);
if (this.view && this.matches.length > 0) {
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
findNext(this.view);
}
};
private setQuery(query: string, replacement: string) {
this.view?.dispatch({
effects: setSearchQuery.of(
new SearchQuery({ search: query, replace: replacement, literal: true }),
),
});
}
/**
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
* node here lets this model — which already owns reveal — measure the obstruction.
*/
readonly setWidgetNode = (node: HTMLElement | null) => {
if (this.widget === node) return;
this.widgetResize?.disconnect();
this.widgetResize = null;
this.widget = node;
if (!node) return;
this.widgetResize = new ResizeObserver(() => this.reposition());
this.widgetResize.observe(node);
this.reposition();
};
private createPanel(view: EditorView): Panel {
this.view = view;
const dom = document.createElement("div");
dom.className = "paseo-file-find";
return {
dom,
top: true,
mount: () => {
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
this.update(view, true);
},
update: (update: ViewUpdate) => {
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
if (
update.docChanged ||
update.selectionSet ||
queryChanged ||
update.geometryChanged ||
update.viewportChanged
)
this.update(view, update.docChanged || queryChanged);
},
destroy: () => {
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
this.publish();
},
};
}
/** Vertical space the widget takes out of the editor, including its inset. */
private clearance(view: EditorView): number {
const widget = this.widget;
if (!widget || !this.snapshot.open) return 0;
const box = widget.getBoundingClientRect();
if (!box.height) return 0;
const editor = view.scrollDOM.getBoundingClientRect();
const clearance =
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
return Math.max(0, clearance);
}
/**
* Move the widget to the opposite corner when it sits on top of the active match
* and the editor has no scroll range left to reveal it. Placement only changes
* when the current corner collides, so navigating matches never makes it bounce.
*
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
* reads while an update is in progress, and every caller here sits inside one.
*/
private reposition() {
const view = this.view;
if (!view || !this.widget || !this.snapshot.open) return;
view.requestMeasure<FindPlacement>({
key: this,
read: () => this.measurePlacement(view),
write: (placement) => {
if (placement === this.snapshot.placement) return;
this.snapshot = { ...this.snapshot, placement };
this.publish();
},
});
}
private measurePlacement(view: EditorView): FindPlacement {
const current = this.snapshot.placement;
const widget = this.widget;
if (!widget) return current;
const selection = view.state.selection.main;
if (selection.empty) return current;
const from = view.coordsAtPos(selection.from);
const to = view.coordsAtPos(selection.to);
if (!from || !to) return current;
const clearance = this.clearance(view);
if (!clearance) return current;
const box = widget.getBoundingClientRect();
const editor = view.scrollDOM.getBoundingClientRect();
const match = {
top: Math.min(from.top, to.top),
bottom: Math.max(from.bottom, to.bottom),
right: Math.max(from.right, to.right),
};
const hits = (placement: FindPlacement) => {
if (match.right <= box.left) return false;
return placement === "top"
? match.top < editor.top + clearance && match.bottom > editor.top
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
};
if (!hits(current)) return current;
const other: FindPlacement = current === "top" ? "bottom" : "top";
return hits(other) ? current : other;
}
private update(view: EditorView, recount: boolean) {
const query = getSearchQuery(view.state);
let limited = this.snapshot.limited;
if (recount) {
this.matches = [];
limited = false;
if (query.valid) {
const cursor = query.getCursor(view.state);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
if (this.matches.length === MATCH_STATUS_LIMIT) {
limited = true;
break;
}
this.matches.push(match.value);
}
}
}
const selection = view.state.selection.main;
const current =
this.matches.findIndex(
(match) => match.from === selection.from && match.to === selection.to,
) + 1;
this.snapshot = {
...this.snapshot,
query: query.search,
replacement: query.replace,
current,
total: this.matches.length,
limited,
readOnly: view.state.readOnly,
};
this.publish();
this.reposition();
}
private publish() {
for (const listener of this.listeners) listener();
}
}
+44 -8
View File
@@ -33,14 +33,13 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { WorkspaceFileSource } from "./source";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -59,6 +58,24 @@ interface FilePreviewBodyProps {
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
@@ -70,13 +87,32 @@ function ReadonlySource({
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<WorkspaceFileSource
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -86,7 +122,7 @@ function TooLargeSource({ size }: { size?: number }) {
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
}
@@ -128,7 +164,7 @@ function FilePreviewBody({
);
}
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
@@ -178,7 +214,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
@@ -203,8 +239,8 @@ export function FilePane({
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = workspaceRoot;
const normalizedFilePath = location.path || null;
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
@@ -1,39 +0,0 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { FileSourceView } from "./view";
/** Read-only source preview. The source module owns platform rendering and its theme. */
export function WorkspaceFileSource(props: {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
}) {
const { t } = useTranslation();
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<FileSourceView
{...props}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
@@ -59,16 +58,14 @@ function VirtualizedSource({
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const { t } = useTranslation();
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
const source = content.replace(/\r\n?/g, "\n");
if (presentation === "highlighted")
return highlightCode(source, filename).map((tokens, index) => ({
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return source
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
@@ -80,63 +77,29 @@ function VirtualizedSource({
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
.map((token) => token.text)
.join("");
const changed =
location.expectedText !== undefined &&
selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
location.expectedText;
const renderLine = useCallback(
({ item }: { item: SourceLine }) => (
<SourceLineView
line={item}
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
/>
),
[changed, location],
);
return (
<View style={styles.root}>
{changed ? (
<Text accessibilityRole="alert" style={styles.notice}>
{t("shell.commandCenter.contentChanged")}
</Text>
) : null}
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
extraData={location}
renderItem={renderLine}
/>
</View>
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
let offset = 0;
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => {
const tokenStart = offset;
offset += token.text.length;
const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
return (
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
{token.text.slice(0, from)}
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
{token.text.slice(to)}
</Text>
);
})}
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
@@ -150,14 +113,9 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
notice: {
padding: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
match: { backgroundColor: theme.colors.terminal.selectionBackground },
unsupported: {
flex: 1,
alignItems: "center",
+9 -41
View File
@@ -1,10 +1,8 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, drawSelection } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
@@ -61,8 +59,6 @@ function ReadonlyCodeMirror({
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
@@ -75,15 +71,7 @@ function ReadonlyCodeMirror({
state: EditorState.create({
doc: values.content,
extensions: [
find.extension,
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({
tabindex: "0",
"aria-label": `Source for ${values.filename}`,
}),
// The preview is never focused, so the browser draws no selection of its own; the
// exact occurrence has to be painted by CodeMirror.
drawSelection(),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
@@ -97,7 +85,7 @@ function ReadonlyCodeMirror({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -117,22 +105,12 @@ function ReadonlyCodeMirror({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
view.dispatch({
selection: { anchor: from, head: to },
// A match far into a long line lands at the scroller edge without the horizontal centre.
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
});
}, [content, location, navigationRevision]);
const line = Math.min(location.lineStart, view.state.doc.lines);
const from = view.state.doc.line(line).from;
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
return (
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
</div>
);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
@@ -144,16 +122,6 @@ function languageFor(input: {
: [];
}
// The Find widget floats inside this frame, so it stays relative; the change notice is the
// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
title: "بحث",
placeholder: "بحث في اللوحة",
close: "إغلاق البحث",
matches: "نتائج البحث",
previous: "التطابق السابق",
next: "التطابق التالي",
toggleReplace: "إظهار الاستبدال",
replaceWith: "استبدال بـ",
replace: "استبدال",
replaceAll: "استبدال الكل",
noMatches: "لا توجد تطابقات",
position: "{{current}} من {{total}}",
total: "{{total}} تطابقات",
},
common: {
back: "خلف",
loading: "تحميل...",
@@ -73,18 +58,6 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
contentTitle: "البحث في محتوى الملفات",
content: "محتوى الملفات",
contentPlaceholder: "البحث في الملفات المحفوظة...",
contentUnavailable: "مساحة العمل غير متاحة",
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
contentBack: "العودة إلى النتائج",
contentOpen: "فتح",
contentLoadingPreview: "جارٍ تحميل المعاينة...",
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
-27
View File
@@ -1,19 +1,4 @@
export const en = {
paneFind: {
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
matches: "Find matches",
previous: "Previous match",
next: "Next match",
toggleReplace: "Toggle replace",
replaceWith: "Replace with",
replace: "Replace",
replaceAll: "Replace all",
noMatches: "No matches",
position: "{{current}} of {{total}}",
total: "{{total}} matches",
},
common: {
back: "Back",
loading: "Loading...",
@@ -70,18 +55,6 @@ export const en = {
close: "Close menu",
},
commandCenter: {
contentTitle: "Search file contents",
content: "File contents",
contentPlaceholder: "Search saved file contents...",
contentUnavailable: "Workspace unavailable",
contentUpdateHost: "Update this host to search file contents",
contentBack: "Back to results",
contentOpen: "Open",
contentLoadingPreview: "Loading preview...",
contentLimited: "Results limited — refine your search",
contentSkippedLargeFiles: "Files over {{size}} were not searched",
contentChanged: "File changed — saved match is no longer at this location",
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar en el panel",
close: "Cerrar búsqueda",
matches: "Coincidencias",
previous: "Coincidencia anterior",
next: "Siguiente coincidencia",
toggleReplace: "Mostrar reemplazo",
replaceWith: "Reemplazar con",
replace: "Reemplazar",
replaceAll: "Reemplazar todo",
noMatches: "Sin coincidencias",
position: "{{current}} de {{total}}",
total: "{{total}} coincidencias",
},
common: {
back: "Atrás",
loading: "Cargando...",
@@ -73,18 +58,6 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
contentTitle: "Buscar contenido de archivos",
content: "Contenido de archivos",
contentPlaceholder: "Buscar contenido guardado...",
contentUnavailable: "Espacio de trabajo no disponible",
contentUpdateHost: "Actualiza este host para buscar contenido",
contentBack: "Volver a resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Cargando vista previa...",
contentLimited: "Resultados limitados — precisa la búsqueda",
contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}",
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
filePlaceholder: "Buscar archivos...",
searchingFiles: "Buscando archivos...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
close: "Fermer la recherche",
matches: "Résultats de recherche",
previous: "Résultat précédent",
next: "Résultat suivant",
toggleReplace: "Afficher le remplacement",
replaceWith: "Remplacer par",
replace: "Remplacer",
replaceAll: "Tout remplacer",
noMatches: "Aucun résultat",
position: "{{current}} sur {{total}}",
total: "{{total}} résultats",
},
common: {
back: "Dos",
loading: "Chargement...",
@@ -74,18 +59,6 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
contentTitle: "Rechercher dans les fichiers",
content: "Contenu des fichiers",
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
contentUnavailable: "Espace de travail indisponible",
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
contentBack: "Retour aux résultats",
contentOpen: "Ouvrir",
contentLoadingPreview: "Chargement de l’aperçu...",
contentLimited: "Résultats limités — précisez la recherche",
contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés",
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
filePlaceholder: "Rechercher des fichiers...",
searchingFiles: "Recherche de fichiers...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
title: "検索",
placeholder: "ペイン内を検索",
close: "検索を閉じる",
matches: "検索結果",
previous: "前の一致",
next: "次の一致",
toggleReplace: "置換を切り替え",
replaceWith: "置換後の文字列",
replace: "置換",
replaceAll: "すべて置換",
noMatches: "一致なし",
position: "{{current}} / {{total}}",
total: "{{total}} 件の一致",
},
common: {
back: "戻る",
loading: "読み込み中...",
@@ -73,18 +58,6 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
contentTitle: "ファイル内容を検索",
content: "ファイル内容",
contentPlaceholder: "保存済みファイルの内容を検索...",
contentUnavailable: "ワークスペースを利用できません",
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
contentBack: "検索結果に戻る",
contentOpen: "開く",
contentLoadingPreview: "プレビューを読み込み中...",
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません",
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
title: "찾기",
placeholder: "패널에서 찾기",
close: "찾기 닫기",
matches: "검색 결과",
previous: "이전 일치 항목",
next: "다음 일치 항목",
toggleReplace: "바꾸기 표시 전환",
replaceWith: "바꿀 내용",
replace: "바꾸기",
replaceAll: "모두 바꾸기",
noMatches: "일치 항목 없음",
position: "{{current}} / {{total}}",
total: "일치 항목 {{total}}개",
},
common: {
back: "뒤로",
loading: "불러오는 중...",
@@ -73,18 +58,6 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
contentTitle: "파일 내용 검색",
content: "파일 내용",
contentPlaceholder: "저장된 파일 내용 검색...",
contentUnavailable: "작업 공간을 사용할 수 없음",
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
contentBack: "결과로 돌아가기",
contentOpen: "열기",
contentLoadingPreview: "미리보기 로딩 중...",
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다",
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar no painel",
close: "Fechar busca",
matches: "Correspondências",
previous: "Correspondência anterior",
next: "Próxima correspondência",
toggleReplace: "Mostrar substituição",
replaceWith: "Substituir por",
replace: "Substituir",
replaceAll: "Substituir tudo",
noMatches: "Nenhuma correspondência",
position: "{{current}} de {{total}}",
total: "{{total}} correspondências",
},
common: {
back: "Voltar",
loading: "Carregando...",
@@ -73,18 +58,6 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
contentTitle: "Buscar conteúdo dos arquivos",
content: "Conteúdo dos arquivos",
contentPlaceholder: "Buscar conteúdo salvo...",
contentUnavailable: "Espaço de trabalho indisponível",
contentUpdateHost: "Atualize este host para buscar conteúdo",
contentBack: "Voltar aos resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Carregando prévia...",
contentLimited: "Resultados limitados — refine a busca",
contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados",
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
filePlaceholder: "Pesquisar arquivos...",
searchingFiles: "Pesquisando arquivos...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
title: "Найти",
placeholder: "Найти в панели",
close: "Закрыть поиск",
matches: "Совпадения",
previous: "Предыдущее совпадение",
next: "Следующее совпадение",
toggleReplace: "Показать замену",
replaceWith: "Заменить на",
replace: "Заменить",
replaceAll: "Заменить всё",
noMatches: "Нет совпадений",
position: "{{current}} из {{total}}",
total: "Совпадений: {{total}}",
},
common: {
back: "Назад",
loading: "Загрузка...",
@@ -73,18 +58,6 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
contentTitle: "Поиск по содержимому файлов",
content: "Содержимое файлов",
contentPlaceholder: "Поиск в сохранённых файлах...",
contentUnavailable: "Рабочее пространство недоступно",
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
contentBack: "Назад к результатам",
contentOpen: "Открыть",
contentLoadingPreview: "Загрузка предпросмотра...",
contentLimited: "Результаты ограничены — уточните поиск",
contentSkippedLargeFiles: "Файлы больше {{size}} не искались",
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
title: "查找",
placeholder: "在窗格中查找",
close: "关闭查找",
matches: "查找结果",
previous: "上一个匹配项",
next: "下一个匹配项",
toggleReplace: "切换替换",
replaceWith: "替换为",
replace: "替换",
replaceAll: "全部替换",
noMatches: "无匹配项",
position: "{{current}} / {{total}}",
total: "{{total}} 个匹配项",
},
common: {
back: "返回",
loading: "加载中...",
@@ -73,18 +58,6 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
contentTitle: "搜索文件内容",
content: "文件内容",
contentPlaceholder: "搜索已保存的文件内容...",
contentUnavailable: "工作区不可用",
contentUpdateHost: "请更新此主机以搜索文件内容",
contentBack: "返回结果",
contentOpen: "打开",
contentLoadingPreview: "正在加载预览...",
contentLimited: "结果已达上限 — 请缩小搜索范围",
contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件",
contentChanged: "文件已更改 — 此位置不再匹配",
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
-1
View File
@@ -46,7 +46,6 @@ export type KeyboardActionId =
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
| "command-center.content"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
@@ -1196,20 +1196,3 @@ describe("direct new-tab target shortcuts", () => {
).toEqual([["ctrl", "shift", "H"]]);
});
});
it("searches contents from terminal focus while preserving explicit Focus overrides", () => {
const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true };
const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const };
expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content");
expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe(
"view.toggle.focus",
);
const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" });
expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus");
const disabled = buildEffectiveBindings({
"view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO,
});
expect(
resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match,
).toBeNull();
});
@@ -156,7 +156,6 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
general: [
"toggle-command-center",
"search-files",
"search-workspace-content",
"show-shortcuts",
"toggle-settings",
"cycle-theme",
@@ -240,7 +239,6 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
"toggle-settings": "settings.shortcuts.help.toggleSettings",
"search-workspace-content": "shell.commandCenter.contentTitle",
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
"cycle-theme": "settings.shortcuts.help.cycleTheme",
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
@@ -1005,7 +1003,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-cmd-shift-f-mac",
action: "view.toggle.focus",
combo: "Cmd+Alt+Shift+F",
combo: "Cmd+Shift+F",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
@@ -1016,7 +1014,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-ctrl-shift-f-non-mac",
action: "view.toggle.focus",
combo: "Ctrl+Alt+Shift+F",
combo: "Ctrl+Shift+F",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
@@ -1025,21 +1023,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
id: "command-center-content-cmd-shift-f-mac",
action: "command-center.content",
combo: "Cmd+Shift+F",
when: { mac: true, commandCenter: false },
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
},
{
id: "command-center-content-ctrl-shift-f-non-mac",
action: "command-center.content",
combo: "Ctrl+Shift+F",
when: { mac: false, commandCenter: false },
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
},
// --- Theme cycling ---
{
id: "theme-cycle-cmd-shift-t-mac",
+1 -5
View File
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -209,10 +209,6 @@ export function routeKeyboardShortcut(
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "command-center.content":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname))
return { kind: "command-center-toggle", nextOpen: true, scope: "content" };
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
case "command-center.files":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
-363
View File
@@ -1,363 +0,0 @@
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useImperativeHandle,
useRef,
useState,
type ReactNode,
type KeyboardEvent,
} from "react";
import {
Text,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputProps,
} from "react-native";
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import {
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
export interface PaneFindHandle {
focus(): void;
}
export interface PaneFindProps {
query: string;
status: string;
canNavigate: boolean;
onQueryChange(query: string): void;
onNext(): void;
onPrevious(): void;
onClose(): void;
replace?: {
value: string;
onChange(value: string): void;
onReplace(): void;
onReplaceAll(): void;
};
}
/**
* The bordered box around one Find input. It owns the field chrome so the query
* row and the replacement row land on the same rails, and so the match count can
* sit inside the query box instead of widening the widget.
*/
const FindField = forwardRef<
EditingTextInputHandle,
{
label: string;
initialValue: string;
height: number;
trailing?: ReactNode;
onChangeText(value: string): void;
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
autoFocus?: boolean;
returnKeyType?: TextInputProps["returnKeyType"];
}
>(function FindField(
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
ref,
) {
const [focused, setFocused] = useState(false);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []);
const fieldStyle = useMemo(
() => [
styles.field,
{ minHeight: height },
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
},
{ focused },
),
],
[focused, height],
);
return (
<View style={fieldStyle}>
<TextInput
ref={ref}
autoFocus={autoFocus}
selectTextOnFocus
initialValue={initialValue}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onFocus={onFocus}
onBlur={onBlur}
accessibilityLabel={label}
placeholder={label}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
returnKeyType={returnKeyType}
style={styles.input}
/>
{trailing}
</View>
);
});
/** Pane-local chrome. The content owner supplies search state and commands. */
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
ref,
) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const controlSize = smallIconButtonChromeFrameSize(isCompact);
const glyphSize = paneContentToolbarIconSize(isCompact);
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
// Keep the replace actions on the same row height as the icon controls beside them.
const actionSize = isCompact ? "sm" : "xs";
const input = useRef<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(null);
useLayoutEffect(() => {
if (input.current?.getText() !== query) input.current?.replaceText(query);
}, [query]);
useLayoutEffect(() => {
if (replace && replacementInput.current?.getText() !== replace.value)
replacementInput.current?.replaceText(replace.value);
}, [replace]);
const [replaceExpanded, setReplaceExpanded] = useState(false);
const focus = useCallback(() => {
input.current?.focus();
const text = input.current?.getText() ?? "";
input.current?.replaceText(text, { start: 0, end: text.length });
}, []);
useImperativeHandle(ref, () => ({ focus }), [focus]);
const onKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const key = event.nativeEvent as TextInputKeyPressEventData & {
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
keyCode?: number;
};
if (isImeComposingKeyboardEvent(key)) return;
if (
(key.metaKey || key.ctrlKey) &&
!key.altKey &&
!key.shiftKey &&
key.key.toLowerCase() === "f"
) {
// RN Web inputs stop keydown before the pane's document listener.
event.preventDefault();
event.stopPropagation();
focus();
} else if (key.key === "Escape" || key.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (key.key === "Escape") onClose();
else if (key.shiftKey) onPrevious();
else onNext();
}
},
[focus, onClose, onNext, onPrevious],
);
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
const gutterStyle = useMemo(
() => [styles.gutter, { width: controlSize, height: rowHeight }],
[controlSize, rowHeight],
);
const matchCount = useMemo(
() => (
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
),
[status, t],
);
return (
<View
style={styles.widget}
accessibilityLabel={t("paneFind.title")}
{...(isWeb
? {
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
onClose();
}
},
}
: {})}
>
{replace ? (
<View style={gutterStyle}>
<ToolbarButton
label={t("paneFind.toggleReplace")}
aria-expanded={replaceExpanded}
compact={isCompact}
onPress={toggleReplace}
>
{replaceExpanded ? (
<ChevronDownIcon size={glyphSize} />
) : (
<ChevronRightIcon size={glyphSize} />
)}
</ToolbarButton>
</View>
) : null}
<View style={styles.rows}>
<View style={styles.row}>
<FindField
ref={input}
autoFocus
label={t("paneFind.placeholder")}
initialValue={query}
height={rowHeight}
returnKeyType="search"
trailing={matchCount}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
/>
<ToolbarButton
label={t("paneFind.previous")}
compact={isCompact}
disabled={!canNavigate}
onPress={onPrevious}
>
<ArrowUpIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton
label={t("paneFind.next")}
compact={isCompact}
disabled={!canNavigate}
onPress={onNext}
>
<ArrowDownIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
<CloseIcon size={glyphSize} />
</ToolbarButton>
</View>
{replace && replaceExpanded ? (
<View style={styles.row}>
<FindField
ref={replacementInput}
label={t("paneFind.replaceWith")}
initialValue={replace.value}
height={rowHeight}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
/>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
) : null}
</View>
</View>
);
});
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
const COLLAPSED_FIELD_HEIGHT = 28;
const FIND_WIDGET_WIDTH = 340;
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
widget: {
width: FIND_WIDGET_WIDTH,
maxWidth: "100%",
flexDirection: "row",
padding: theme.spacing[1.5],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
},
// The disclosure column spans both rows so the two fields share a leading rail.
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
field: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
input: {
flex: 1,
minWidth: 0,
paddingHorizontal: 0,
paddingVertical: 0,
color: theme.colors.foreground,
outlineWidth: 0,
outlineColor: "transparent",
...geometry.fieldTextSm,
},
status: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Ghost actions sit on the field's rail, so they carry the field's padding.
replaceAction: { paddingHorizontal: theme.spacing[2] },
};
});
+1 -6
View File
@@ -75,12 +75,7 @@ export function usePaneContext(): PaneContextValue {
}
export function usePaneFocus(): PaneFocusContextValue {
const value = usePaneFocusIfAny();
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
return value;
}
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
return useContext(PaneFocusContext);
}
+108 -12
View File
@@ -85,10 +85,15 @@ import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type {
CreatePaseoWorktreeInput,
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
@@ -123,6 +128,11 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
const ThemedFolderPlus = withUnistyles(FolderPlus);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -747,10 +757,19 @@ function normalizeBranchDetails(
return names.map((name) => ({ name, committerDate: 0 }));
}
/**
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
* and the screen — if still mounted under another route — has to drop its pending state itself.
*/
type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
clearConsumedDraft: () => void;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
@@ -759,6 +778,8 @@ interface SubmitDraftInput {
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -860,9 +881,13 @@ interface CreateChatAgentInput {
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
labels: {
composerStateRequired: string;
selectModel: string;
@@ -918,8 +943,9 @@ function buildComposerInitialValues(input: {
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const { text, attachments, cwd } = payload;
if (!composerState) {
throw new Error(input.labels.composerStateRequired);
@@ -947,10 +973,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
});
submitWorkspaceDraft({
return await submitWorkspaceDraft({
clearConsumedDraft,
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
@@ -959,6 +988,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
});
}
@@ -1021,7 +1052,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
const {
serverId,
clearDraft,
@@ -1049,6 +1080,33 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
composerState,
initialSetup,
});
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
if (!input.isStillOnCreateScreen()) {
await createWorkspaceAgentInBackground({
clearConsumedDraft: input.clearConsumedDraft,
createAgent: () =>
requestWorkspaceDraftAgent(input.resolveClient(), {
workspaceId,
config: buildWorkspaceDraftAgentConfig({
provider: submission.provider,
cwd: submission.cwd,
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId
? { thinkingOptionId: submission.thinkingOptionId }
: {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
}),
text: text.trim(),
clientMessageId,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
}),
});
return "background";
}
useCreateFlowStore.getState().setPending({
serverId,
draftId,
@@ -1082,6 +1140,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
@@ -1591,6 +1650,7 @@ export function NewWorkspaceScreen({
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
@@ -1696,11 +1756,12 @@ export function NewWorkspaceScreen({
}, []);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
if (!connectedClient?.isConnected) {
throw new Error(t("newWorkspace.errors.hostDisconnected"));
}
return client;
}, [client, isConnected, t]);
return connectedClient;
}, [selectedServerId, t]);
const clientReady = isConnected && Boolean(client);
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
@@ -2043,31 +2104,49 @@ export function NewWorkspaceScreen({
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
// Nothing navigated, so this screen may still be mounted under another route. Release
// the pending lock it would otherwise keep forever.
if (outcome === "background") {
setPendingAction(null);
}
return;
}
setPendingAction("chat");
await runCreateChatAgent({
const outcome = await runCreateChatAgent({
payload,
composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
isStillOnCreateScreen,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2077,16 +2156,20 @@ export function NewWorkspaceScreen({
},
[
composerState,
draftContextScopeKey,
draftId,
chatDraft.clear,
draftKey,
ensureWorkspace,
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
],
);
@@ -2095,6 +2178,7 @@ export function NewWorkspaceScreen({
setErrorMessage(null);
await updateFormPreferences({ launchTarget });
setPendingAction("terminal");
let outcome: SubmitOutcome = "background";
await runCreateTerminalWorkspace({
cwd: selectedSourceDirectory ?? "",
prompt: terminalPromptText,
@@ -2128,9 +2212,20 @@ export function NewWorkspaceScreen({
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
},
serverId: selectedServerId,
navigate: (targetServerId, workspaceId, target) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
// The terminal is spawned and fed its command before this runs, so skipping the
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
// terminals when the workspace is next visited.
navigate: (targetServerId, workspaceId, target) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2139,6 +2234,7 @@ export function NewWorkspaceScreen({
}
}, [
ensureWorkspace,
isStillOnCreateScreen,
launchTarget,
queryClient,
selectedServerId,
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./background-handoff";
const DRAFT_ID = "draft_1";
const DRAFT_KEY = "new-workspace";
const SCOPE_KEY = "draft:draft_1";
function saveDraft(text: string) {
useDraftStore
.getState()
.saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } });
}
function draftText(): string | undefined {
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
}
function captureCleanup() {
return captureWorkspaceDraftCleanup({
draftId: DRAFT_ID,
draftKey: DRAFT_KEY,
clearDraft: (lifecycle) =>
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
draftContextScopeKey: SCOPE_KEY,
});
}
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
}
beforeEach(() => {
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
useCreateFlowStore.setState({ pendingByDraftId: {} });
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
saveDraft("do the thing");
});
describe("createWorkspaceAgentInBackground", () => {
it("preserves context added to a newer draft without editing its text", async () => {
await run(async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/new-repo",
provider: "codex",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
"/work/new-repo",
);
});
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
let calls = 0;
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/repo",
provider: "claude",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
await run(async () => {
calls += 1;
});
expect(calls).toBe(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
expect(draftText()).toBe("");
});
it("preserves context changed while workspace creation was pending", async () => {
const clearConsumedDraft = captureCleanup();
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
await run(async () => undefined, clearConsumedDraft);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(draftText()).toBe("do the thing");
});
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
await run(async () => undefined);
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
});
it("rethrows a failed creation and keeps the prompt for retry", async () => {
const failure = new Error("daemon exploded");
await expect(
run(async () => {
throw failure;
}),
).rejects.toThrow(failure);
expect(draftText()).toBe("do the thing");
});
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
await run(async () => {
saveDraft("a different idea");
});
expect(draftText()).toBe("a different idea");
});
it("keeps a newer draft written while workspace creation was pending", async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
const clearConsumedDraft = captureCleanup();
saveDraft("a newer workspace idea");
await run(async () => undefined, clearConsumedDraft);
expect(draftText()).toBe("a newer workspace idea");
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
});
});
@@ -0,0 +1,54 @@
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
interface WorkspaceDraftCleanupInput {
draftId?: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftContextScopeKey: string | null;
}
/** Capture ownership before workspace creation starts, not when its response arrives. */
export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void {
const draftId = input.draftId?.trim() ?? "";
const scopeKey = input.draftContextScopeKey ?? "";
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId];
const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
return () => {
// These stores update independently. A context-only edit does not bump the text version.
// Any newer draft state owns the whole draft, including its unchanged context.
if (
useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion ||
useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup ||
useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments
) {
return;
}
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
if (scopeKey) {
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
}
input.clearDraft("sent");
};
}
/**
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
* so this path issues the request itself. The agent tab opens on its own when the workspace is
* next visited — `reconcileTabs` auto-opens agents.
*
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
*/
export async function createWorkspaceAgentInBackground(input: {
clearConsumedDraft: () => void;
createAgent: () => Promise<unknown>;
}): Promise<void> {
await input.createAgent();
input.clearConsumedDraft();
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { isNewWorkspaceScreenActive } from "./screen-presence";
describe("isNewWorkspaceScreenActive", () => {
it("is active while the screen is mounted and the route is /new", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true);
});
it("is inactive once the screen has been popped off the stack", () => {
expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false);
});
it("is inactive while another route sits on top of the screen", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false);
});
it("is inactive on a workspace route", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe(
false,
);
});
});
@@ -0,0 +1,41 @@
import { usePathname } from "expo-router";
import { useCallback, useEffect, useRef } from "react";
const NEW_WORKSPACE_PATHNAME = "/new";
export interface NewWorkspaceScreenPresence {
isMounted: boolean;
pathname: string;
}
/**
* Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have
* moved on. Both signals are needed: popping to a workspace unmounts the screen while its last
* observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath.
*/
export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean {
return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME;
}
export function useNewWorkspaceScreenPresence(): () => boolean {
const pathname = usePathname();
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return useCallback(
() =>
isNewWorkspaceScreenActive({
isMounted: isMountedRef.current,
pathname: pathnameRef.current,
}),
[],
);
}
@@ -1,24 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
function createInput(closeExplorerAfterOpen: boolean) {
return {
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
filePath: "src/app.tsx",
persistenceKey: "server:workspace",
closeExplorerAfterOpen,
showMobileAgent: vi.fn(),
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
focusWorkspaceTab: vi.fn(),
requestFileNavigation: vi.fn(),
};
}
describe("openWorkspaceFileInFocusedPane", () => {
describe("openWorkspaceFileFromExplorer", () => {
it("closes the phone overlay after opening a file", () => {
const input = createInput(true);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(input);
expect(input.showMobileAgent).toHaveBeenCalledOnce();
});
@@ -26,29 +24,10 @@ describe("openWorkspaceFileInFocusedPane", () => {
it("keeps the tablet dock open after opening a file", () => {
const input = createInput(false);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(input);
expect(input.showMobileAgent).not.toHaveBeenCalled();
expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce();
expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab");
});
it("asks for navigation every time, so reopening the same occurrence recentres its pane", () => {
const input = createInput(false);
input.location = { path: "src/app.tsx", lineStart: 1201, columnStart: 17 };
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).toHaveBeenCalledTimes(2);
expect(input.requestFileNavigation).toHaveBeenLastCalledWith("file-tab");
});
it("does not navigate when no tab could be opened", () => {
const input = { ...createInput(false), openWorkspaceTabInFocusedPane: vi.fn(() => null) };
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,6 @@
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
import {
FOCUSED_PANE_PLACEMENT,
@@ -9,8 +8,8 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileInput {
location: WorkspaceFileLocation;
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
persistenceKey: string | null;
closeExplorerAfterOpen: boolean;
showMobileAgent: () => void;
@@ -20,24 +19,16 @@ interface OpenWorkspaceFileInput {
placement?: WorkspaceTabPlacement,
) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
}
/**
* Opens a workspace file in the focused pane and navigates to it.
*
* The navigation request is the part callers keep forgetting: a file target is identity-stable, so
* reopening the same path — or the same occurrence — reuses its tab and would otherwise leave the
* pane wherever the reader had scrolled it. Every explicit activation asks for a reveal.
*/
export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): void {
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
if (input.closeExplorerAfterOpen) {
input.showMobileAgent();
}
if (!input.persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation(input.location);
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
if (!location) {
return;
}
@@ -46,9 +37,7 @@ export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): v
createWorkspaceFileTabTarget(location),
FOCUSED_PANE_PLACEMENT,
);
if (!tabId) {
return;
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
}
input.focusWorkspaceTab(input.persistenceKey, tabId);
input.requestFileNavigation(tabId);
}
@@ -1647,13 +1647,17 @@ function WorkspaceScreenContent({
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
[openTab],
);
// File targets stay identity-stable so the same path reuses its tab. Navigation requests live
// with the tabs in the layout store, so an opener outside this screen — the Command Center —
// asks for the same reveal through the same capability.
const fileNavigationRevisionByTabId = useWorkspaceLayoutStore(
(state) => state.fileNavigationRevisionByTabId,
);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
Record<string, number>
>({});
const requestFileNavigation = useCallback((tabId: string) => {
setFileNavigationRevisionByTabId((current) => ({
...current,
[tabId]: (current[tabId] ?? 0) + 1,
}));
}, []);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
@@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
export type CommandCenterScope = "files" | "content" | null;
export type CommandCenterScope = "files" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
@@ -110,12 +110,6 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
/**
* Bumped whenever a file tab is explicitly activated. File targets are identity-stable so the
* same path reuses its tab; without this an activation of an unchanged location would look like
* a no-op and leave the pane wherever the reader had scrolled it.
*/
fileNavigationRevisionByTabId: Record<string, number>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
sidePaneIdByWorkspace: Record<string, string | null>;
openTab: (input: OpenWorkspaceTabInput) => string | null;
@@ -126,7 +120,6 @@ interface WorkspaceLayoutStore {
ensureSidePane: (workspaceKey: string) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
replaceTab: (
workspaceKey: string,
@@ -214,9 +207,6 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
columnStart: z.number().int().positive().optional(),
columnEnd: z.number().int().positive().optional(),
expectedText: z.string().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
@@ -777,7 +767,6 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
fileNavigationRevisionByTabId: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
openTab: (input) => {
@@ -1051,16 +1040,6 @@ export function createWorkspaceLayoutStore(
};
});
},
requestFileNavigation: (tabId) => {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId) return;
set((state) => ({
fileNavigationRevisionByTabId: {
...state.fileNavigationRevisionByTabId,
[normalizedTabId]: (state.fileNavigationRevisionByTabId[normalizedTabId] ?? 0) + 1,
},
}));
},
focusTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -1,6 +0,0 @@
/** Renders a byte count in the units the file pane and file search already show. */
export function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
-13
View File
@@ -1,16 +1,3 @@
export function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
/**
* Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter
* or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or
* workspace-relative path is returned untouched.
*/
export function normalizePathSeparators(value: string): string {
return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value;
}
function isWindowsShapedPath(value: string): boolean {
return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveWorkspaceFileSelection,
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
@@ -8,10 +7,10 @@ import {
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("clamps line ranges and leaves the path as the producer gave it", () => {
it("normalizes paths and valid line ranges", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src/app.ts",
path: "src\\app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
@@ -35,7 +34,7 @@ describe("normalizeWorkspaceFileLocation", () => {
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
});
});
@@ -98,33 +97,13 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
// A workspace-relative path is the host's own identity, so its backslash is a file name
// character. Rewriting it here is what opened a different file than the search result.
expect(
resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/a\\b.txt",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
@@ -204,50 +183,3 @@ describe("resolveWorkspaceFilePaths", () => {
).toBeNull();
});
});
it("selects exact UTF-16 columns and refuses a stale saved match", () => {
const location = {
path: "a.ts",
lineStart: 2,
columnStart: 5,
columnEnd: 11,
expectedText: "NEEDLE",
};
expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
from: 10,
to: 16,
changed: false,
});
expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
from: 10,
to: 10,
changed: true,
});
expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
});
it("does not select an identical occurrence on another line after the saved line disappears", () => {
expect(
resolveWorkspaceFileSelection("needle", {
path: "a.ts",
lineStart: 3,
columnStart: 1,
columnEnd: 7,
expectedText: "needle",
}),
).toEqual({ from: 0, to: 0, changed: true });
});
it("preserves literal filename and workspace whitespace through location and host paths", () => {
const location = { path: " leading.txt ", lineStart: 1 };
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
absolutePath: "/workspace / leading.txt ",
relativePath: " leading.txt ",
});
});
it("preserves a literal backslash file name through location identity", () => {
const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" };
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
});
+5 -49
View File
@@ -1,18 +1,11 @@
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
import { isAbsolutePath } from "@/utils/path";
export type OpenFileDisposition = "main" | "preferred" | "side";
export interface WorkspaceFileLocation {
path: string;
/** One-based source line; CRLF, CR and LF each separate lines. */
lineStart?: number;
lineEnd?: number;
/** One-based UTF-16 column on lineStart. */
columnStart?: number;
/** Exclusive UTF-16 column on the same line. */
columnEnd?: number;
/** Original saved occurrence; a mismatch reveals the location without selecting text. */
expectedText?: string;
}
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
@@ -29,25 +22,16 @@ export function normalizeWorkspaceFileLocation(
return null;
}
// The path is an identity the host already produced, so it is kept verbatim: a backslash is a
// file name character outside Windows, and rewriting it here opened a different file than the
// one a search result or explorer row named. Callers holding a path scraped out of text
// normalize it themselves before arriving.
const path = location.path;
const path = location.path.trim().replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
const columnStart = normalizeLineNumber(location.columnStart);
const columnEnd = normalizeLineNumber(location.columnEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
...(lineStart && columnStart ? { columnStart } : {}),
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
@@ -57,12 +41,7 @@ export function workspaceFileLocationsEqual(
right: WorkspaceFileLocation,
): boolean {
return (
left.path === right.path &&
left.lineStart === right.lineStart &&
left.lineEnd === right.lineEnd &&
left.columnStart === right.columnStart &&
left.columnEnd === right.columnEnd &&
left.expectedText === right.expectedText
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
);
}
@@ -166,8 +145,8 @@ export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = normalizePathSeparators(input.path);
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
@@ -197,26 +176,3 @@ export function resolveWorkspaceFilePaths(input: {
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
/** Resolves a saved location without manufacturing a selection when its text has moved. */
export function resolveWorkspaceFileSelection(
content: string,
location: WorkspaceFileLocation,
): { from: number; to: number; changed: boolean } {
const lines = content.split("\n");
const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
const line = lines[index];
const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
let to = from;
if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
else if (endIndex > index)
to =
lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
lines[endIndex].length;
const changed =
location.expectedText !== undefined &&
(index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
return { from, to: changed ? from : to, changed };
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/server": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/server": "0.8.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"zod": "^4.4.3"
},
"devDependencies": {
-28
View File
@@ -1,4 +1,3 @@
import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import {
@@ -4492,33 +4491,6 @@ export class DaemonClient {
return payload.directory;
}
async searchWorkspaceContent(
input: { cwd: string; query: string },
options: { signal?: AbortSignal } = {},
): Promise<WorkspaceContentSearchResult> {
if (options.signal?.aborted)
return { status: "error", code: "cancelled", message: "Search cancelled" };
const requestId = this.createRequestId();
const cancel = () => {
void this.sendCorrelatedSessionRequest({
message: { type: "fs.content.cancel.request", searchRequestId: requestId },
responseType: "fs.content.cancel.response",
}).catch(() => undefined);
};
const response = this.sendCorrelatedSessionRequest({
requestId,
message: { type: "fs.content.search.request", ...input },
responseType: "fs.content.search.response",
});
options.signal?.addEventListener("abort", cancel, { once: true });
if (options.signal?.aborted) cancel();
try {
return (await response).result;
} finally {
options.signal?.removeEventListener("abort", cancel);
}
}
async readFile(
cwd: string,
path: string,
-1
View File
@@ -1,4 +1,3 @@
export type { FileReadResult } from "./daemon-client.js";
import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type {
@@ -5,6 +5,7 @@ const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { chromium } = require("playwright");
const { extractFile } = require("@electron/asar");
const EXECUTABLE_NAME = "Paseo";
const SMOKE_TIMEOUT_MS = 60_000;
@@ -805,9 +806,23 @@ async function stopCliDaemon({ appPath, env }) {
});
}
function assertLinuxDesktopIdentity(appPath) {
if (process.platform === "linux") {
const metadata = JSON.parse(
extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(),
);
if (metadata.desktopName !== `${EXECUTABLE_NAME}.desktop`) {
throw new Error(
`Packaged Linux desktop identity ${JSON.stringify(metadata.desktopName)} does not match ${EXECUTABLE_NAME}.desktop`,
);
}
}
}
async function smokePackagedDesktopApp({ appPath }) {
const executablePath = getExecutablePath(appPath);
assertExecutable(executablePath, "Packaged app executable");
assertLinuxDesktopIdentity(appPath);
ensureLinuxSandboxPermissions(appPath);
await smokeColdCliDaemonStart({ appPath });
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -35,6 +35,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
@@ -43,5 +44,6 @@
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
"wait-on": "8.0.5"
}
},
"desktopName": "Paseo.desktop"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-56
View File
@@ -2688,57 +2688,6 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
}),
]);
export const WorkspaceContentMatchSchema = z.object({
path: z.string(),
line: z.number().int().positive(),
columnStart: z.number().int().positive(),
columnEnd: z.number().int().positive(),
text: z.string(),
snippet: z.string(),
snippetMatchStart: z.number().int().nonnegative(),
snippetMatchEnd: z.number().int().nonnegative(),
});
export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("ok"),
matches: z.array(WorkspaceContentMatchSchema),
limited: z.boolean(),
/**
* Per-file byte ceiling the host applied. Files above it are never opened, so a result set
* is complete only for files at or under this size. Absent on hosts that do not report it.
*/
// COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after
// 2027-03-10 once the daemon floor reports it.
maxFileBytes: z.number().int().positive().optional(),
}),
z.object({
status: z.literal("error"),
code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
message: z.string(),
}),
]);
export const WorkspaceContentSearchRequestSchema = z.object({
type: z.literal("fs.content.search.request"),
requestId: z.string(),
cwd: z.string(),
query: z.string(),
});
export const WorkspaceContentCancelRequestSchema = z.object({
type: z.literal("fs.content.cancel.request"),
requestId: z.string(),
searchRequestId: z.string(),
});
export const WorkspaceContentSearchResponseSchema = z.object({
type: z.literal("fs.content.search.response"),
payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
});
export const WorkspaceContentCancelResponseSchema = z.object({
type: z.literal("fs.content.cancel.response"),
payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
});
export type WorkspaceContentMatch = z.infer<typeof WorkspaceContentMatchSchema>;
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
export const FileSubscribeRequestSchema = z.object({
type: z.literal("fs.file.subscribe.request"),
cwd: z.string(),
@@ -3262,8 +3211,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
WorkspaceContentSearchRequestSchema,
WorkspaceContentCancelRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
@@ -3569,7 +3516,6 @@ export const ServerInfoStatusPayloadSchema = z
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
workspaceContentSearch: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -6671,8 +6617,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
WorkspaceContentSearchResponseSchema,
WorkspaceContentCancelResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -71,11 +71,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/highlight": "0.8.0-beta.1",
"@getpaseo/plugin": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/highlight": "0.8.0",
"@getpaseo/plugin": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -19,6 +19,7 @@ import { toAgentPayload } from "./agent-projections.js";
import { projectTimelineRows } from "./timeline-projection.js";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type {
@@ -68,6 +69,12 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
async function drainAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<void> {
for await (const _ of generator) {
// Drain provider events while AgentManager subscribers observe them.
}
}
function waitForAgentLifecycle(
manager: AgentManager,
agentId: string,
@@ -519,6 +526,32 @@ class TestAgentSession implements AgentSession {
async close(): Promise<void> {}
}
class ResumeTrackingTestAgentClient extends TestAgentClient {
private readonly retryStarted = deferred<void>();
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeOverrides.push(config);
const signalRetryStarted = () => this.retryStarted.resolve();
return new (class extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
signalRetryStarted();
return await super.startTurn();
}
})({
provider: this.provider,
cwd: config?.cwd ?? process.cwd(),
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
});
}
waitForRetryStart(): Promise<void> {
return this.retryStarted.promise;
}
}
class McpCapableTestAgentSession extends TestAgentSession {
override readonly capabilities = {
...TEST_CAPABILITIES,
@@ -3677,6 +3710,201 @@ test("updateProviderRegistry removes providers omitted from the next registry",
expect(removedClient.createSessionCalls).toBe(0);
});
test("retires loaded agents when their plugin provider is replaced", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class OriginalPluginClient extends TestAgentClient {
session: CloseRecordingTestAgentSession | null = null;
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new CloseRecordingTestAgentSession(config);
session.describePersistence = () => ({
provider,
sessionId: "plugin-session",
});
this.session = session;
return session;
}
}
const original = new OriginalPluginClient();
const replacement = new TestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: original },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
retiredProviders: [provider],
});
await manager.waitForAgentClose(created.id);
expect(original.session?.closed).toBe(true);
expect(manager.getAgent(created.id)).toBeNull();
const resumed = await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(created.id);
expect(replacement.resumeOverrides).toEqual([
expect.objectContaining({ cwd: workdir, provider }),
]);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("a prompt after provider replacement reopens the stale session", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class StalePluginSession extends CloseRecordingTestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
throw new StaleProviderSessionError("stale-bridge");
}
}
class StalePluginClient extends TestAgentClient {
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new StalePluginSession(config);
session.describePersistence = () => ({
provider,
sessionId: "plugin-session",
});
return session;
}
}
const staleClient = new StalePluginClient();
const replacement = new ResumeTrackingTestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: staleClient },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
});
const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger);
expect(dispatch.disposition).toBe("turn_started");
await replacement.waitForRetryStart();
const result = await manager.waitForAgentEvent(created.id);
expect(result.status).toBe("idle");
expect(replacement.resumeOverrides).toHaveLength(1);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("a replacement prompt recovers when the retired session fails to start", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class StaleReplacementSession extends TestAgentSession {
private starts = 0;
override async startTurn(): Promise<{ turnId: string }> {
this.starts += 1;
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
return { turnId: "initial-turn" };
}
override async interrupt(): Promise<void> {
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
turnId: "initial-turn",
});
}
}
class StaleReplacementClient extends TestAgentClient {
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new StaleReplacementSession(config);
session.describePersistence = () => ({ provider, sessionId: "plugin-session" });
return session;
}
}
const replacement = new ResumeTrackingTestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: new StaleReplacementClient() },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
rescueTimeouts: { interruptSessionMs: 20 },
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
const initial = manager.streamAgent(created.id, "initial");
void drainAsyncGenerator(initial);
await manager.waitForAgentRunStart(created.id);
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
});
const dispatch = await startAgentRun(manager, created.id, "replacement", logger, {
replaceRunning: true,
});
expect(dispatch.disposition).toBe("turn_started");
await replacement.waitForRetryStart();
const result = await manager.waitForAgentEvent(created.id);
expect(result.status).toBe("idle");
expect(replacement.resumeOverrides).toHaveLength(1);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("createAgent passes explicit model strings through to the provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -77,6 +77,7 @@ import {
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
@@ -775,6 +776,7 @@ export class AgentManager {
updateProviderRegistry(input: {
providerDefinitions: ProviderEnabledMap;
clients: ProviderClientMap;
retiredProviders?: readonly AgentProvider[];
}): void {
this.providerEnabled.clear();
this.providerDefinitions.clear();
@@ -791,6 +793,18 @@ export class AgentManager {
this.clients.set(provider, client);
}
}
for (const provider of input.retiredProviders ?? []) {
for (const agent of this.agents.values()) {
if (agent.provider !== provider) continue;
void this.closeAgent(agent.id).catch((error) => {
this.logger.warn(
{ err: error, agentId: agent.id, provider },
"Failed to close agent after provider retirement",
);
});
}
}
}
getRegisteredProviderIds(): AgentProvider[] {
@@ -2360,6 +2374,13 @@ export class AgentManager {
if (pendingRun.settled) {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
@@ -2885,6 +2906,9 @@ export class AgentManager {
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId);
if (agent.inFlightPermissionResponses.has(requestId)) {
throw new Error("A response to this permission request is already being submitted");
}
agent.inFlightPermissionResponses.add(requestId);
try {
@@ -3079,8 +3103,11 @@ export class AgentManager {
epoch: this.timelineStore.getEpoch(agentId),
});
}
await this.refreshRuntimeInfo(agent);
// Rewind stages provider events under the run lock; publish its final state directly.
this.refreshSessionPersistence(agent);
await this.refreshSessionState(agent, { emit: false });
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.complete",
@@ -4231,14 +4258,18 @@ export class AgentManager {
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
const previousSessionId = agent.persistence?.sessionId ?? null;
this.refreshSessionPersistence(agent);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent);
}
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
}
void this.refreshRuntimeInfo(agent);
}
private async onStreamTimelineEvent(params: {
@@ -4443,6 +4474,7 @@ export class AgentManager {
): void {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
this.broadcastAgentAttention(agent, "permission");
}
@@ -4457,6 +4489,7 @@ export class AgentManager {
}): void {
const { agent, event, options, flags } = params;
agent.pendingPermissions.delete(event.requestId);
this.refreshSessionPersistence(agent);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
flags.shouldDispatchEvent = false;
@@ -8,6 +8,7 @@ import type {
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
@@ -21,7 +22,9 @@ export type AgentRunController = Pick<
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
>;
> & {
reloadAgentSession(agentId: string): Promise<unknown>;
};
export interface StartAgentRunOptions {
replaceRunning?: boolean;
@@ -78,6 +81,14 @@ async function startOrReplaceRun(
return { iterator, replaced };
}
async function drainAgentRunIterator(
iterator: AsyncGenerator<import("./agent-sdk-types.js").AgentStreamEvent>,
): Promise<void> {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
}
export async function startAgentRun(
agentManager: AgentRunController,
agentId: string,
@@ -104,6 +115,26 @@ export async function startAgentRun(
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
return { disposition: "out_of_band" };
}
try {
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
logger.info({ agentId, err: error }, "Provider session went stale; reopening from persistence");
// The live session belongs to a retired plugin runtime. Reload swaps in a
// fresh session on the current runtime while preserving history and labels.
await agentManager.reloadAgentSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
}
}
async function startAgentRunInner(
agentManager: AgentRunController,
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: StartAgentRunOptions,
): Promise<{ disposition: PromptDispatchDisposition }> {
const snapshot = agentManager.getAgent(agentId);
const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options);
if (steered?.disposition === "steered") {
return steered;
@@ -122,8 +153,17 @@ export async function startAgentRun(
);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
try {
await drainAgentRunIterator(iterator);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
logger.info(
{ agentId, err: error },
"Provider session went stale; reopening from persistence",
);
await agentManager.reloadAgentSession(agentId);
const retry = await startOrReplaceRun(agentManager, agentId, prompt, options);
await drainAgentRunIterator(retry.iterator);
}
logger.trace(
{
@@ -8,6 +8,10 @@ import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import {
isStaleProviderSessionError,
StaleProviderSessionError,
} from "./stale-provider-session-error.js";
const CAPABILITIES = [
"prompt.message",
@@ -25,6 +29,10 @@ interface ProviderHarnessOptions {
function createProviderHarness(options: ProviderHarnessOptions = {}) {
let listener: ((event: ProviderEvent) => void) | null = null;
let closeCount = 0;
let resolveClosed!: () => void;
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const inputs: ProviderInput[] = [];
const emit = (event: ProviderEvent) => listener?.(event);
const capabilities = options.capabilities ?? CAPABILITIES;
@@ -180,6 +188,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
},
async close() {
closeCount += 1;
resolveClosed();
},
};
@@ -191,7 +200,12 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
},
};
return { registration, inputs, closeCount: () => closeCount };
return {
registration,
inputs,
closeCount: () => closeCount,
waitForClose: () => closed,
};
}
function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) {
@@ -258,6 +272,84 @@ describe("PluginAgentClientRegistry", () => {
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
});
test("closes a stale session after its plugin provider is replaced", async () => {
const old = createProviderHarness();
const next = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
try {
registry.replace([old.registration]);
const stale = await registry.clients()[old.registration.id]!.createSession({
provider: old.registration.id,
cwd: "/workspace",
});
const persistence = stale.describePersistence();
expect(persistence).not.toBeNull();
registry.replace([next.registration]);
await old.waitForClose();
expect(old.closeCount()).toBe(1);
await expect(stale.close()).resolves.toBeUndefined();
const replacement = registry.clients()[next.registration.id];
expect(replacement).toBeDefined();
const resumed = await replacement!.resumeSession(persistence!, {
cwd: "/workspace",
});
await expect(
resumed.startTurn("after reload", { clientMessageId: "after-reload" }),
).resolves.toEqual({ turnId: "turn-1" });
expect(next.inputs).toContainEqual(
expect.objectContaining({
type: "session.open",
history: "replay",
persistence: {
version: 1,
data: { token: "root" },
},
}),
);
await resumed.close();
} finally {
await registry.shutdown();
}
});
test("prompting a stale session raises StaleProviderSessionError", async () => {
const old = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
try {
registry.replace([old.registration]);
const stale = await registry.clients()[old.registration.id]!.createSession({
provider: old.registration.id,
cwd: "/workspace",
});
registry.replace([]);
await old.waitForClose();
expect(old.closeCount()).toBe(1);
const failure = await stale
.startTurn("after reload", { clientMessageId: "after-reload" })
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(StaleProviderSessionError);
expect(isStaleProviderSessionError(failure)).toBe(true);
expect(isStaleProviderSessionError(new Error("Provider connection is closed"))).toBe(false);
expect(isStaleProviderSessionError(new Error("boom"))).toBe(false);
} finally {
await registry.shutdown();
}
});
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -58,6 +58,7 @@ import {
} from "./create-agent-mode.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
interface Deferred<Value> {
promise: Promise<Value>;
@@ -130,6 +131,10 @@ class ProviderRuntime {
return this.connection?.capabilities ?? [];
}
get isClosed(): boolean {
return this.closed;
}
async isAvailable(): Promise<boolean> {
await this.getConnection();
return true;
@@ -559,6 +564,7 @@ class ProviderRuntimeSession {
sessionId: this.providerSessionId,
prompt,
});
if (this.terminal) throw new StaleProviderSessionError(this.id);
const pending = deferred<Extract<ProviderEvent, { type: "session.prompt_result" }>>();
this.prompts.set(prompt.clientMessageId, pending);
try {
@@ -568,6 +574,9 @@ class ProviderRuntimeSession {
prompt,
});
return (await pending.promise).result;
} catch (error) {
if (this.terminal || this.runtime.isClosed) throw new StaleProviderSessionError(this.id);
throw error;
} finally {
this.prompts.delete(prompt.clientMessageId);
}
@@ -623,6 +632,8 @@ class ProviderRuntimeSession {
requestId: randomUUID(),
sessionId: this.providerSessionId,
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
@@ -2281,6 +2281,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
const withoutPlugin = manager.replacePluginProviders([]);
expect(manager.hasProvider(registration.id)).toBe(false);
expect(withoutPlugin.clients[registration.id]).toBeUndefined();
expect(withoutPlugin.retiredProviders).toEqual([registration.id]);
} finally {
await manager.shutdown();
manager.destroy();
@@ -191,6 +191,7 @@ export interface AgentManagerProviderState {
>
>;
clients: Partial<Record<AgentProvider, AgentClient>>;
retiredProviders?: readonly AgentProvider[];
}
interface ProviderLoadOptions {
@@ -402,6 +403,9 @@ export class ProviderSnapshotManager {
this.createAgentManagerState(this.generation.definitions, clients);
this.pluginProviders.replace(registrations);
const plugins = this.pluginProviders.definitions();
const retiredProviders = Object.keys(previousPlugins).filter(
(provider) => previousPlugins[provider] !== plugins[provider],
);
const definitions = { ...this.generation.definitions };
const changed = new Set<AgentProvider>();
for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) {
@@ -415,7 +419,7 @@ export class ProviderSnapshotManager {
const generation = this.createGeneration(definitions, this.providerOverrides);
const state = this.createAgentManagerState(definitions, clients);
this.installGeneration(generation, clients, changed);
return state;
return { ...state, retiredProviders };
}
private ensureClient(

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